-
Notifications
You must be signed in to change notification settings - Fork 415
Remove mobx from quotas page #2174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
bb62009
9f3f8dd
405bdb7
a6454a9
48cd773
93c2ef1
e99d002
084b0af
bb4001b
37c9237
74ad66d
e104821
d463bdf
ae19d34
b79fc88
9547217
d923cd2
8154932
8e1a39f
c4812be
833334f
0975d8f
3e1c8ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -158,3 +158,5 @@ dist | |
|
|
||
| # Gemini CLI | ||
| .gemini/settings.json | ||
|
|
||
| tests/**/playwright-report/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,165 +9,253 @@ | |
| * by the Apache License, Version 2.0 | ||
| */ | ||
|
|
||
| import { Alert, AlertIcon, Button, DataTable, Result } from '@redpanda-data/ui'; | ||
| import { Alert, AlertIcon, Button, DataTable, Result, Skeleton } from '@redpanda-data/ui'; | ||
| import { useNavigate, useSearch } from '@tanstack/react-router'; | ||
| import { SkipIcon } from 'components/icons'; | ||
| import { computed, makeObservable } from 'mobx'; | ||
| import { observer } from 'mobx-react'; | ||
|
|
||
| import { appGlobal } from '../../../state/app-global'; | ||
| import { api } from '../../../state/backend-api'; | ||
| import { type QuotaResponseSetting, QuotaType } from '../../../state/rest-interfaces'; | ||
| import { toJson } from '../../../utils/json-utils'; | ||
| import { DefaultSkeleton, InfoText } from '../../../utils/tsx-utils'; | ||
| import { Link } from 'components/redpanda-ui/components/typography'; | ||
| import { useQuotasQuery } from 'hooks/use-quotas-query'; | ||
| import { useMemo } from 'react'; | ||
|
|
||
| import { | ||
| Quota_EntityType, | ||
| type Quota_Value, | ||
| Quota_ValueType, | ||
| } from '../../../protogen/redpanda/api/dataplane/v1/quota_pb'; | ||
| import type { QuotaResponseSetting } from '../../../state/rest-interfaces'; | ||
| import { InfoText } from '../../../utils/tsx-utils'; | ||
| import { prettyBytes, prettyNumber } from '../../../utils/utils'; | ||
| import PageContent from '../../misc/page-content'; | ||
| import Section from '../../misc/section'; | ||
| import { PageComponent, type PageInitHelper } from '../page'; | ||
|
|
||
| @observer | ||
| class QuotasList extends PageComponent { | ||
| constructor(p: Readonly<{ matchedPath: string }>) { | ||
| super(p); | ||
| makeObservable(this); | ||
| /** | ||
| * Maps REST API quota value types to protobuf ValueType enum | ||
| */ | ||
| const mapValueTypeToProto = (key: string): Quota_ValueType => { | ||
| switch (key) { | ||
| case 'producer_byte_rate': | ||
| return Quota_ValueType.PRODUCER_BYTE_RATE; | ||
| case 'consumer_byte_rate': | ||
| return Quota_ValueType.CONSUMER_BYTE_RATE; | ||
| case 'controller_mutation_rate': | ||
| return Quota_ValueType.CONTROLLER_MUTATION_RATE; | ||
| case 'request_percentage': | ||
| return Quota_ValueType.REQUEST_PERCENTAGE; | ||
| default: | ||
| return Quota_ValueType.UNSPECIFIED; | ||
| } | ||
| }; | ||
|
|
||
| initPage(p: PageInitHelper): void { | ||
| p.title = 'Quotas'; | ||
| p.addBreadcrumb('Quotas', '/quotas'); | ||
|
|
||
| this.refreshData(true); | ||
| appGlobal.onRefresh = () => this.refreshData(true); | ||
| /** | ||
| * Maps REST API entity type to protobuf EntityType enum | ||
| */ | ||
| const mapEntityTypeToProto = (entityType: string): Quota_EntityType => { | ||
| switch (entityType) { | ||
| case 'client-id': | ||
| return Quota_EntityType.CLIENT_ID; | ||
| case 'user': | ||
| return Quota_EntityType.USER; | ||
| case 'ip': | ||
| return Quota_EntityType.IP; | ||
| default: | ||
| return Quota_EntityType.UNSPECIFIED; | ||
| } | ||
| }; | ||
|
|
||
| refreshData(force: boolean) { | ||
| if (api.userData !== null && api.userData !== undefined && !api.userData.canListQuotas) { | ||
| return; | ||
| } | ||
| api.refreshQuotas(force); | ||
| } | ||
| const QuotasList = () => { | ||
| const navigate = useNavigate({ from: '/quotas' }); | ||
| const search = useSearch({ from: '/quotas' }); | ||
| const { data, error, isLoading } = useQuotasQuery(); | ||
|
|
||
| render() { | ||
| if (api.userData !== null && api.userData !== undefined && !api.userData.canListQuotas) { | ||
| return PermissionDenied; | ||
| } | ||
| if (api.Quotas === undefined) { | ||
| return DefaultSkeleton; | ||
| const quotasData = useMemo(() => { | ||
| if (!data?.items) { | ||
| return []; | ||
| } | ||
|
|
||
| const warning = | ||
| api.Quotas === null ? ( | ||
| <Alert status="warning" style={{ marginBottom: '1em' }} variant="solid"> | ||
| <AlertIcon /> | ||
| You do not have the necessary permissions to view Quotas | ||
| </Alert> | ||
| ) : null; | ||
|
|
||
| const resources = this.quotasList; | ||
| const formatBytes = (x: undefined | number) => | ||
| x ? ( | ||
| prettyBytes(x) | ||
| ) : ( | ||
| <span style={{ opacity: 0.3 }}> | ||
| <SkipIcon /> | ||
| </span> | ||
| ); | ||
| const formatRate = (x: undefined | number) => | ||
| x ? ( | ||
| prettyNumber(x) | ||
| ) : ( | ||
| <span style={{ opacity: 0.3 }}> | ||
| <SkipIcon /> | ||
| </span> | ||
| return data.items.map((item) => { | ||
| const entityType = mapEntityTypeToProto(item.entityType); | ||
| const entityName = item.entityName; | ||
|
|
||
| // Map entity type to display string | ||
| let displayType: 'client-id' | 'user' | 'ip' | 'unknown' = 'unknown'; | ||
| if (entityType === Quota_EntityType.CLIENT_ID) { | ||
| displayType = 'client-id'; | ||
| } else if (entityType === Quota_EntityType.USER) { | ||
| displayType = 'user'; | ||
| } else if (entityType === Quota_EntityType.IP) { | ||
| displayType = 'ip'; | ||
| } | ||
|
|
||
| // Transform REST API settings to protobuf Value format | ||
| const values: Quota_Value[] = item.settings.map( | ||
| (setting: QuotaResponseSetting): Quota_Value => ({ | ||
| valueType: mapValueTypeToProto(setting.key), | ||
| value: setting.value, | ||
| $typeName: 'redpanda.api.dataplane.v1.Quota.Value', | ||
| }) | ||
| ); | ||
|
|
||
| return { | ||
| eqKey: `${entityType}-${entityName}`, | ||
| entityType: displayType, | ||
| entityName: entityName || undefined, | ||
| values, | ||
| }; | ||
| }); | ||
| }, [data]); | ||
|
|
||
| const formatBytes = (values: Quota_Value[], valueType: Quota_ValueType) => { | ||
| const value = values.find((v) => v.valueType === valueType)?.value; | ||
| return value ? ( | ||
| prettyBytes(value) | ||
| ) : ( | ||
| <span style={{ opacity: 0.3 }}> | ||
| <SkipIcon /> | ||
| </span> | ||
| ); | ||
| }; | ||
|
|
||
| const formatRate = (values: Quota_Value[], valueType: Quota_ValueType) => { | ||
| const value = values.find((v) => v.valueType === valueType)?.value; | ||
| return value ? ( | ||
| prettyNumber(value) | ||
| ) : ( | ||
| <span style={{ opacity: 0.3 }}> | ||
| <SkipIcon /> | ||
| </span> | ||
| ); | ||
| }; | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <PageContent> | ||
| <Section> | ||
| {warning} | ||
|
|
||
| <DataTable<{ | ||
| eqKey: string; | ||
| entityType: 'client-id' | 'user' | 'ip'; | ||
| entityName?: string | undefined; | ||
| settings: QuotaResponseSetting[]; | ||
| }> | ||
| columns={[ | ||
| { | ||
| size: 100, // Assuming '100px' translates to '100' | ||
| header: 'Type', | ||
| accessorKey: 'entityType', | ||
| }, | ||
| { | ||
| size: 100, // 'auto' width replaced with an example number | ||
| header: 'Name', | ||
| accessorKey: 'entityName', | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: () => <InfoText tooltip="Limit throughput of produce requests">Producer Rate</InfoText>, | ||
| accessorKey: 'producerRate', | ||
| cell: ({ row: { original } }) => | ||
| formatBytes(original.settings.first((k) => k.key === QuotaType.PRODUCER_BYTE_RATE)?.value), | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: () => <InfoText tooltip="Limit throughput of fetch requests">Consumer Rate</InfoText>, | ||
| accessorKey: 'consumerRate', | ||
| cell: ({ row: { original } }) => | ||
| formatBytes(original.settings.first((k) => k.key === QuotaType.CONSUMER_BYTE_RATE)?.value), | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: () => ( | ||
| <InfoText tooltip="Limit rate of topic mutation requests, including create, add, and delete partition, in number of partitions per second"> | ||
| Controller Mutation Rate | ||
| </InfoText> | ||
| ), | ||
| accessorKey: 'controllerMutationRate', | ||
| cell: ({ row: { original } }) => | ||
| formatRate(original.settings.first((k) => k.key === QuotaType.CONTROLLER_MUTATION_RATE)?.value), | ||
| }, | ||
| ]} | ||
| data={resources} | ||
| /> | ||
| <Skeleton height="400px" /> | ||
| </Section> | ||
| </PageContent> | ||
| ); | ||
| } | ||
|
|
||
| @computed get quotasList() { | ||
| const quotaResponse = api.Quotas; | ||
| if (!quotaResponse || quotaResponse.error) { | ||
| return []; | ||
| if (error) { | ||
| const isPermissionError = error.message.includes('permission') || error.message.includes('forbidden'); | ||
|
|
||
| if (isPermissionError) { | ||
| return ( | ||
| <PageContent> | ||
| <Section> | ||
| <Result | ||
| extra={ | ||
| <Link href="https://docs.redpanda.com/docs/manage/console/" target="_blank"> | ||
| <Button variant="solid">Redpanda Console documentation for roles and permissions</Button> | ||
| </Link> | ||
| } | ||
| status={403} | ||
| title="Forbidden" | ||
| userMessage={ | ||
| <p> | ||
| You are not allowed to view this page. | ||
| <br /> | ||
| Contact the administrator if you think this is an error. | ||
| </p> | ||
|
||
| } | ||
| /> | ||
| </Section> | ||
| </PageContent> | ||
| ); | ||
| } | ||
|
|
||
| return quotaResponse.items.map((x) => ({ ...x, eqKey: toJson(x) })); | ||
| return ( | ||
| <PageContent> | ||
| <Section> | ||
| <Alert status="warning" style={{ marginBottom: '1em' }} variant="solid"> | ||
| <AlertIcon /> | ||
| {error.message || 'Failed to load quotas'} | ||
| </Alert> | ||
| </Section> | ||
| </PageContent> | ||
| ); | ||
| } | ||
|
|
||
| if (data?.error) { | ||
| return ( | ||
| <PageContent> | ||
| <Section> | ||
| <Alert status="warning" style={{ marginBottom: '1em' }} variant="solid"> | ||
| <AlertIcon /> | ||
| {data.error} | ||
| </Alert> | ||
| </Section> | ||
| </PageContent> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const PermissionDenied = ( | ||
| <> | ||
| <PageContent key="quotasNoPerms"> | ||
| return ( | ||
| <PageContent> | ||
| <Section> | ||
| <Result | ||
| extra={ | ||
| <a href="https://docs.redpanda.com/docs/manage/console/" rel="noopener noreferrer" target="_blank"> | ||
| <Button variant="solid">Redpanda Console documentation for roles and permissions</Button> | ||
| </a> | ||
| } | ||
| status={403} | ||
| title="Forbidden" | ||
| userMessage={ | ||
| <p> | ||
| You are not allowed to view this page. | ||
| <br /> | ||
| Contact the administrator if you think this is an error. | ||
| </p> | ||
| } | ||
| <DataTable<{ | ||
| eqKey: string; | ||
| entityType: 'client-id' | 'user' | 'ip' | 'unknown'; | ||
| entityName?: string | undefined; | ||
| values: Quota_Value[]; | ||
| }> | ||
| columns={[ | ||
| { | ||
| size: 100, | ||
| header: 'Type', | ||
| accessorKey: 'entityType', | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: 'Name', | ||
| accessorKey: 'entityName', | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: () => <InfoText tooltip="Limit throughput of produce requests">Producer Rate</InfoText>, | ||
| accessorKey: 'producerRate', | ||
| cell: ({ row: { original } }) => formatBytes(original.values, Quota_ValueType.PRODUCER_BYTE_RATE), | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: () => <InfoText tooltip="Limit throughput of fetch requests">Consumer Rate</InfoText>, | ||
| accessorKey: 'consumerRate', | ||
| cell: ({ row: { original } }) => formatBytes(original.values, Quota_ValueType.CONSUMER_BYTE_RATE), | ||
| }, | ||
| { | ||
| size: 100, | ||
| header: () => ( | ||
| <InfoText tooltip="Limit rate of topic mutation requests, including create, add, and delete partition, in number of partitions per second"> | ||
| Controller Mutation Rate | ||
| </InfoText> | ||
| ), | ||
| accessorKey: 'controllerMutationRate', | ||
| cell: ({ row: { original } }) => formatRate(original.values, Quota_ValueType.CONTROLLER_MUTATION_RATE), | ||
| }, | ||
| ]} | ||
| data={quotasData} | ||
| defaultPageSize={50} | ||
| onPaginationChange={(updater) => { | ||
| const newPagination = | ||
| typeof updater === 'function' | ||
| ? updater({ pageIndex: search.page ?? 0, pageSize: search.pageSize ?? 50 }) | ||
| : updater; | ||
|
|
||
| navigate({ | ||
| search: (prev) => ({ | ||
| ...prev, | ||
| page: newPagination.pageIndex, | ||
| pageSize: newPagination.pageSize, | ||
| }), | ||
| replace: true, | ||
| }); | ||
| }} | ||
| pagination={{ | ||
| pageIndex: search.page ?? 0, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should default to page 1? or do we start the index at 0.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We start at 0 |
||
| pageSize: search.pageSize ?? 50, | ||
| }} | ||
| /> | ||
| </Section> | ||
| </PageContent> | ||
| </> | ||
| ); | ||
| ); | ||
| }; | ||
|
|
||
| export default QuotasList; | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels like we should be doing this inside the hook like useListQuotas
Also having
$typeNamehere feels awkward, should we try to create connect query key or similar to keep REST/gRPC calls use the same cache