Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,5 @@ dist

# Gemini CLI
.gemini/settings.json

tests/**/playwright-report/
348 changes: 218 additions & 130 deletions frontend/src/components/pages/quotas/quotas-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
);
Copy link
Contributor

@malinskibeniamin malinskibeniamin Feb 4, 2026

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 $typeName here feels awkward, should we try to create connect query key or similar to keep REST/gRPC calls use the same cache


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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use a Heading component

}
/>
</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,
Copy link
Contributor

@malinskibeniamin malinskibeniamin Feb 19, 2026

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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;
Loading
Loading