diff --git a/frontend/.gitignore b/frontend/.gitignore index ba1513a94c..a053e71e9f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -158,3 +158,5 @@ dist # Gemini CLI .gemini/settings.json + +tests/**/playwright-report/ diff --git a/frontend/src/components/pages/quotas/quotas-list.tsx b/frontend/src/components/pages/quotas/quotas-list.tsx index eba45809ec..c51407b4ba 100644 --- a/frontend/src/components/pages/quotas/quotas-list.tsx +++ b/frontend/src/components/pages/quotas/quotas-list.tsx @@ -9,165 +9,212 @@ * by the Apache License, Version 2.0 */ -import { Alert, AlertIcon, Button, DataTable, Result } from '@redpanda-data/ui'; +import { create } from '@bufbuild/protobuf'; +import { useQuery } from '@connectrpc/connect-query'; +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 { useMemo } from 'react'; + +import { + ListQuotasRequestSchema, + Quota_EntityType, + type Quota_Value, + Quota_ValueType, +} from '../../../protogen/redpanda/api/dataplane/v1/quota_pb'; +import { listQuotas } from '../../../protogen/redpanda/api/dataplane/v1/quota-QuotaService_connectquery'; +import { MAX_PAGE_SIZE } from '../../../react-query/react-query.utils'; +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 protobuf EntityType enum to display string + */ +const mapEntityTypeToDisplay = (entityType: Quota_EntityType): 'client-id' | 'user' | 'ip' | 'unknown' => { + switch (entityType) { + case Quota_EntityType.CLIENT_ID: + case Quota_EntityType.CLIENT_ID_PREFIX: + return 'client-id'; + case Quota_EntityType.USER: + return 'user'; + case Quota_EntityType.IP: + return 'ip'; + default: + return 'unknown'; } +}; - initPage(p: PageInitHelper): void { - p.title = 'Quotas'; - p.addBreadcrumb('Quotas', '/quotas'); +const request = create(ListQuotasRequestSchema, { pageSize: MAX_PAGE_SIZE }); - this.refreshData(true); - appGlobal.onRefresh = () => this.refreshData(true); - } +const QuotasList = () => { + const navigate = useNavigate({ from: '/quotas' }); + const search = useSearch({ from: '/quotas' }); + const { data, error, isLoading } = useQuery(listQuotas, request, { + refetchOnMount: 'always', + }); - refreshData(force: boolean) { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListQuotas) { - return; + const quotasData = useMemo(() => { + if (!data?.quotas) { + return []; } - api.refreshQuotas(force); - } - render() { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListQuotas) { - return PermissionDenied; - } - if (api.Quotas === undefined) { - return DefaultSkeleton; - } + return data.quotas.map((quota) => { + const entityType = quota.entity?.entityType ?? Quota_EntityType.UNSPECIFIED; + const entityName = quota.entity?.entityName; - const warning = - api.Quotas === null ? ( - - - You do not have the necessary permissions to view Quotas - - ) : null; - - const resources = this.quotasList; - const formatBytes = (x: undefined | number) => - x ? ( - prettyBytes(x) - ) : ( - - - - ); - const formatRate = (x: undefined | number) => - x ? ( - prettyNumber(x) - ) : ( - - - - ); + return { + eqKey: `${entityType}-${entityName}`, + entityType: mapEntityTypeToDisplay(entityType), + entityName: entityName || undefined, + values: quota.values, + }; + }); + }, [data]); + + const formatBytes = (values: Quota_Value[], valueType: Quota_ValueType) => { + const value = values.find((v) => v.valueType === valueType)?.value; + return value ? ( + prettyBytes(value) + ) : ( + + + + ); + }; + const formatRate = (values: Quota_Value[], valueType: Quota_ValueType) => { + const value = values.find((v) => v.valueType === valueType)?.value; + return value ? ( + prettyNumber(value) + ) : ( + + + + ); + }; + + if (isLoading) { return (
- {warning} - - - 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: () => Producer Rate, - accessorKey: 'producerRate', - cell: ({ row: { original } }) => - formatBytes(original.settings.first((k) => k.key === QuotaType.PRODUCER_BYTE_RATE)?.value), - }, - { - size: 100, - header: () => Consumer Rate, - accessorKey: 'consumerRate', - cell: ({ row: { original } }) => - formatBytes(original.settings.first((k) => k.key === QuotaType.CONSUMER_BYTE_RATE)?.value), - }, - { - size: 100, - header: () => ( - - Controller Mutation Rate - - ), - accessorKey: 'controllerMutationRate', - cell: ({ row: { original } }) => - formatRate(original.settings.first((k) => k.key === QuotaType.CONTROLLER_MUTATION_RATE)?.value), - }, - ]} - data={resources} - /> +
); } - @computed get quotasList() { - const quotaResponse = api.Quotas; - if (!quotaResponse || quotaResponse.error) { - return []; + if (error) { + console.error('[QuotasList] Error fetching quotas:', error.message, error); + const isPermissionError = error.message.includes('permission') || error.message.includes('forbidden'); + + if (isPermissionError) { + return ( + +
+ + + + } + status={403} + title="Forbidden" + userMessage={ +

+ You are not allowed to view this page. +
+ Contact the administrator if you think this is an error. +

+ } + /> +
+
+ ); } - return quotaResponse.items.map((x) => ({ ...x, eqKey: toJson(x) })); + return ( + +
+ + + {error.message || 'Failed to load quotas'} + +
+
+ ); } -} -const PermissionDenied = ( - <> - + return ( +
- - - - } - status={403} - title="Forbidden" - userMessage={ -

- You are not allowed to view this page. -
- Contact the administrator if you think this is an error. -

- } + + columns={[ + { + size: 100, + header: 'Type', + accessorKey: 'entityType', + }, + { + size: 100, + header: 'Name', + accessorKey: 'entityName', + }, + { + size: 100, + header: () => Producer Rate, + accessorKey: 'producerRate', + cell: ({ row: { original } }) => formatBytes(original.values, Quota_ValueType.PRODUCER_BYTE_RATE), + }, + { + size: 100, + header: () => Consumer Rate, + accessorKey: 'consumerRate', + cell: ({ row: { original } }) => formatBytes(original.values, Quota_ValueType.CONSUMER_BYTE_RATE), + }, + { + size: 100, + header: () => ( + + Controller Mutation Rate + + ), + 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, + pageSize: search.pageSize ?? 50, + }} />
- -); + ); +}; export default QuotasList; diff --git a/frontend/src/components/pages/schemas/schema-details.tsx b/frontend/src/components/pages/schemas/schema-details.tsx index 1c5ce2e8cb..fb460d26cb 100644 --- a/frontend/src/components/pages/schemas/schema-details.tsx +++ b/frontend/src/components/pages/schemas/schema-details.tsx @@ -64,7 +64,7 @@ const { ToastContainer } = createStandaloneToast(); const SchemaDetailsView: React.FC<{ subjectName: string }> = ({ subjectName: subjectNameProp }) => { const { subjectName: subjectNameParam } = routeApi.useParams(); - const navigate = useNavigate({ from: '/schema-registry/subjects/$subjectName' }); + const navigate = useNavigate({ from: '/schema-registry/subjects/$subjectName/' }); const search = routeApi.useSearch(); const toast = useToast(); @@ -265,7 +265,7 @@ export function getFormattedSchemaText(schema: SchemaRegistryVersionedSchema) { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic const SubjectDefinition = (p: { subject: SchemaRegistrySubjectDetails }) => { const toast = useToast(); - const navigate = useNavigate({ from: '/schema-registry/subjects/$subjectName' }); + const navigate = useNavigate({ from: '/schema-registry/subjects/$subjectName/' }); const search = routeApi.useSearch(); const queryClient = useQueryClient(); diff --git a/frontend/src/components/redpanda-ui/components/data-table.tsx b/frontend/src/components/redpanda-ui/components/data-table.tsx index 49ef8abb5b..c32b59a5d6 100644 --- a/frontend/src/components/redpanda-ui/components/data-table.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table.tsx @@ -418,6 +418,7 @@ export function DataTablePagination({ table, testId }: DataTablePaginatio className="hidden size-8 lg:flex" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} + aria-label="First Page" > Go to first page @@ -428,6 +429,7 @@ export function DataTablePagination({ table, testId }: DataTablePaginatio className="size-8" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} + aria-label="Previous Page" > Go to previous page @@ -438,6 +440,7 @@ export function DataTablePagination({ table, testId }: DataTablePaginatio className="size-8" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} + aria-label="Next Page" > Go to next page @@ -448,6 +451,7 @@ export function DataTablePagination({ table, testId }: DataTablePaginatio className="hidden size-8 lg:flex" onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} + aria-label="Last Page" > Go to last page diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 29dd091e47..4608e71c3f 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -436,21 +436,21 @@ export interface FileRoutesByFullPath { '/security/$tab': typeof SecurityTabRoute; '/shadowlinks/create': typeof ShadowlinksCreateRoute; '/transforms/$transformName': typeof TransformsTransformNameRoute; - '/agents': typeof AgentsIndexRoute; - '/connect-clusters': typeof ConnectClustersIndexRoute; - '/debug-bundle': typeof DebugBundleIndexRoute; - '/groups': typeof GroupsIndexRoute; - '/knowledgebases': typeof KnowledgebasesIndexRoute; - '/login': typeof LoginIndexRoute; - '/mcp-servers': typeof McpServersIndexRoute; - '/overview': typeof OverviewIndexRoute; - '/schema-registry': typeof SchemaRegistryIndexRoute; - '/secrets': typeof SecretsIndexRoute; - '/security': typeof SecurityIndexRoute; - '/shadowlinks': typeof ShadowlinksIndexRoute; - '/topics': typeof TopicsIndexRoute; - '/transcripts': typeof TranscriptsIndexRoute; - '/transforms': typeof TransformsIndexRoute; + '/agents/': typeof AgentsIndexRoute; + '/connect-clusters/': typeof ConnectClustersIndexRoute; + '/debug-bundle/': typeof DebugBundleIndexRoute; + '/groups/': typeof GroupsIndexRoute; + '/knowledgebases/': typeof KnowledgebasesIndexRoute; + '/login/': typeof LoginIndexRoute; + '/mcp-servers/': typeof McpServersIndexRoute; + '/overview/': typeof OverviewIndexRoute; + '/schema-registry/': typeof SchemaRegistryIndexRoute; + '/secrets/': typeof SecretsIndexRoute; + '/security/': typeof SecurityIndexRoute; + '/shadowlinks/': typeof ShadowlinksIndexRoute; + '/topics/': typeof TopicsIndexRoute; + '/transcripts/': typeof TranscriptsIndexRoute; + '/transforms/': typeof TransformsIndexRoute; '/connect-clusters/$clusterName/$connector': typeof ConnectClustersClusterNameConnectorRoute; '/connect-clusters/$clusterName/create-connector': typeof ConnectClustersClusterNameCreateConnectorRoute; '/debug-bundle/progress/$jobId': typeof DebugBundleProgressJobIdRoute; @@ -463,11 +463,11 @@ export interface FileRoutesByFullPath { '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; '/topics/$topicName/produce-record': typeof TopicsTopicNameProduceRecordRoute; - '/connect-clusters/$clusterName': typeof ConnectClustersClusterNameIndexRoute; - '/knowledgebases/$knowledgebaseId': typeof KnowledgebasesKnowledgebaseIdIndexRoute; - '/rp-connect/$pipelineId': typeof RpConnectPipelineIdIndexRoute; - '/shadowlinks/$name': typeof ShadowlinksNameIndexRoute; - '/topics/$topicName': typeof TopicsTopicNameIndexRoute; + '/connect-clusters/$clusterName/': typeof ConnectClustersClusterNameIndexRoute; + '/knowledgebases/$knowledgebaseId/': typeof KnowledgebasesKnowledgebaseIdIndexRoute; + '/rp-connect/$pipelineId/': typeof RpConnectPipelineIdIndexRoute; + '/shadowlinks/$name/': typeof ShadowlinksNameIndexRoute; + '/topics/$topicName/': typeof TopicsTopicNameIndexRoute; '/knowledgebases/$knowledgebaseId/documents/$documentId': typeof KnowledgebasesKnowledgebaseIdDocumentsDocumentIdRoute; '/rp-connect/secrets/$secretId/edit': typeof RpConnectSecretsSecretIdEditRoute; '/schema-registry/subjects/$subjectName/add-version': typeof SchemaRegistrySubjectsSubjectNameAddVersionRoute; @@ -477,7 +477,7 @@ export interface FileRoutesByFullPath { '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; '/security/users/$userName/details': typeof SecurityUsersUserNameDetailsRoute; - '/schema-registry/subjects/$subjectName': typeof SchemaRegistrySubjectsSubjectNameIndexRoute; + '/schema-registry/subjects/$subjectName/': typeof SchemaRegistrySubjectsSubjectNameIndexRoute; } export interface FileRoutesByTo { '/': typeof IndexRoute; @@ -637,21 +637,21 @@ export interface FileRouteTypes { | '/security/$tab' | '/shadowlinks/create' | '/transforms/$transformName' - | '/agents' - | '/connect-clusters' - | '/debug-bundle' - | '/groups' - | '/knowledgebases' - | '/login' - | '/mcp-servers' - | '/overview' - | '/schema-registry' - | '/secrets' - | '/security' - | '/shadowlinks' - | '/topics' - | '/transcripts' - | '/transforms' + | '/agents/' + | '/connect-clusters/' + | '/debug-bundle/' + | '/groups/' + | '/knowledgebases/' + | '/login/' + | '/mcp-servers/' + | '/overview/' + | '/schema-registry/' + | '/secrets/' + | '/security/' + | '/shadowlinks/' + | '/topics/' + | '/transcripts/' + | '/transforms/' | '/connect-clusters/$clusterName/$connector' | '/connect-clusters/$clusterName/create-connector' | '/debug-bundle/progress/$jobId' @@ -664,11 +664,11 @@ export interface FileRouteTypes { | '/security/users/create' | '/shadowlinks/$name/edit' | '/topics/$topicName/produce-record' - | '/connect-clusters/$clusterName' - | '/knowledgebases/$knowledgebaseId' - | '/rp-connect/$pipelineId' - | '/shadowlinks/$name' - | '/topics/$topicName' + | '/connect-clusters/$clusterName/' + | '/knowledgebases/$knowledgebaseId/' + | '/rp-connect/$pipelineId/' + | '/shadowlinks/$name/' + | '/topics/$topicName/' | '/knowledgebases/$knowledgebaseId/documents/$documentId' | '/rp-connect/secrets/$secretId/edit' | '/schema-registry/subjects/$subjectName/add-version' @@ -678,7 +678,7 @@ export interface FileRouteTypes { | '/security/roles/$roleName/details' | '/security/roles/$roleName/update' | '/security/users/$userName/details' - | '/schema-registry/subjects/$subjectName'; + | '/schema-registry/subjects/$subjectName/'; fileRoutesByTo: FileRoutesByTo; to: | '/' @@ -927,105 +927,105 @@ declare module '@tanstack/react-router' { '/transforms/': { id: '/transforms/'; path: '/transforms'; - fullPath: '/transforms'; + fullPath: '/transforms/'; preLoaderRoute: typeof TransformsIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/transcripts/': { id: '/transcripts/'; path: '/transcripts'; - fullPath: '/transcripts'; + fullPath: '/transcripts/'; preLoaderRoute: typeof TranscriptsIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/topics/': { id: '/topics/'; path: '/topics'; - fullPath: '/topics'; + fullPath: '/topics/'; preLoaderRoute: typeof TopicsIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/shadowlinks/': { id: '/shadowlinks/'; path: '/shadowlinks'; - fullPath: '/shadowlinks'; + fullPath: '/shadowlinks/'; preLoaderRoute: typeof ShadowlinksIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/security/': { id: '/security/'; path: '/security'; - fullPath: '/security'; + fullPath: '/security/'; preLoaderRoute: typeof SecurityIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/secrets/': { id: '/secrets/'; path: '/secrets'; - fullPath: '/secrets'; + fullPath: '/secrets/'; preLoaderRoute: typeof SecretsIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/schema-registry/': { id: '/schema-registry/'; path: '/schema-registry'; - fullPath: '/schema-registry'; + fullPath: '/schema-registry/'; preLoaderRoute: typeof SchemaRegistryIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/overview/': { id: '/overview/'; path: '/overview'; - fullPath: '/overview'; + fullPath: '/overview/'; preLoaderRoute: typeof OverviewIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/mcp-servers/': { id: '/mcp-servers/'; path: '/mcp-servers'; - fullPath: '/mcp-servers'; + fullPath: '/mcp-servers/'; preLoaderRoute: typeof McpServersIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/login/': { id: '/login/'; path: '/login'; - fullPath: '/login'; + fullPath: '/login/'; preLoaderRoute: typeof LoginIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/knowledgebases/': { id: '/knowledgebases/'; path: '/knowledgebases'; - fullPath: '/knowledgebases'; + fullPath: '/knowledgebases/'; preLoaderRoute: typeof KnowledgebasesIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/groups/': { id: '/groups/'; path: '/groups'; - fullPath: '/groups'; + fullPath: '/groups/'; preLoaderRoute: typeof GroupsIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/debug-bundle/': { id: '/debug-bundle/'; path: '/debug-bundle'; - fullPath: '/debug-bundle'; + fullPath: '/debug-bundle/'; preLoaderRoute: typeof DebugBundleIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/connect-clusters/': { id: '/connect-clusters/'; path: '/connect-clusters'; - fullPath: '/connect-clusters'; + fullPath: '/connect-clusters/'; preLoaderRoute: typeof ConnectClustersIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/agents/': { id: '/agents/'; path: '/agents'; - fullPath: '/agents'; + fullPath: '/agents/'; preLoaderRoute: typeof AgentsIndexRouteImport; parentRoute: typeof rootRouteImport; }; @@ -1144,35 +1144,35 @@ declare module '@tanstack/react-router' { '/topics/$topicName/': { id: '/topics/$topicName/'; path: '/topics/$topicName'; - fullPath: '/topics/$topicName'; + fullPath: '/topics/$topicName/'; preLoaderRoute: typeof TopicsTopicNameIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/shadowlinks/$name/': { id: '/shadowlinks/$name/'; path: '/shadowlinks/$name'; - fullPath: '/shadowlinks/$name'; + fullPath: '/shadowlinks/$name/'; preLoaderRoute: typeof ShadowlinksNameIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/rp-connect/$pipelineId/': { id: '/rp-connect/$pipelineId/'; path: '/rp-connect/$pipelineId'; - fullPath: '/rp-connect/$pipelineId'; + fullPath: '/rp-connect/$pipelineId/'; preLoaderRoute: typeof RpConnectPipelineIdIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/knowledgebases/$knowledgebaseId/': { id: '/knowledgebases/$knowledgebaseId/'; path: '/knowledgebases/$knowledgebaseId'; - fullPath: '/knowledgebases/$knowledgebaseId'; + fullPath: '/knowledgebases/$knowledgebaseId/'; preLoaderRoute: typeof KnowledgebasesKnowledgebaseIdIndexRouteImport; parentRoute: typeof rootRouteImport; }; '/connect-clusters/$clusterName/': { id: '/connect-clusters/$clusterName/'; path: '/connect-clusters/$clusterName'; - fullPath: '/connect-clusters/$clusterName'; + fullPath: '/connect-clusters/$clusterName/'; preLoaderRoute: typeof ConnectClustersClusterNameIndexRouteImport; parentRoute: typeof rootRouteImport; }; @@ -1263,7 +1263,7 @@ declare module '@tanstack/react-router' { '/schema-registry/subjects/$subjectName/': { id: '/schema-registry/subjects/$subjectName/'; path: '/schema-registry/subjects/$subjectName'; - fullPath: '/schema-registry/subjects/$subjectName'; + fullPath: '/schema-registry/subjects/$subjectName/'; preLoaderRoute: typeof SchemaRegistrySubjectsSubjectNameIndexRouteImport; parentRoute: typeof rootRouteImport; }; diff --git a/frontend/src/routes/quotas.tsx b/frontend/src/routes/quotas.tsx index 275593d846..f7408879e3 100644 --- a/frontend/src/routes/quotas.tsx +++ b/frontend/src/routes/quotas.tsx @@ -10,18 +10,34 @@ */ import { createFileRoute } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; import { ScaleIcon } from 'components/icons'; +import { useLayoutEffect } from 'react'; +import { z } from 'zod'; import QuotasList from '../components/pages/quotas/quotas-list'; +import { uiState } from '../state/ui-state'; + +const quotasSearchSchema = z.object({ + page: fallback(z.number().int().min(0).optional(), 0), + pageSize: fallback(z.number().int().min(10).max(100).optional(), 50), +}); export const Route = createFileRoute('/quotas')({ staticData: { title: 'Quotas', icon: ScaleIcon, }, + validateSearch: zodValidator(quotasSearchSchema), component: QuotasWrapper, }); function QuotasWrapper() { - return ; + // Set page title and breadcrumbs in route wrapper for early execution + useLayoutEffect(() => { + uiState.pageBreadcrumbs = [{ title: 'Quotas', linkTo: '' }]; + uiState.pageTitle = 'Quotas'; + }, []); + + return ; } diff --git a/frontend/tests/test-variant-console/playwright.config.ts b/frontend/tests/test-variant-console/playwright.config.ts index e3dea76594..728eed63e8 100644 --- a/frontend/tests/test-variant-console/playwright.config.ts +++ b/frontend/tests/test-variant-console/playwright.config.ts @@ -74,6 +74,17 @@ const config = defineConfig({ ...devices['Desktop Chrome'], permissions: ['clipboard-read', 'clipboard-write'], }, + testIgnore: '**/quotas/*.spec.ts', + }, + // Isolated project for quota tests (avoid RPK conflicts) + { + name: 'quotas-isolated', + testMatch: '**/quotas/*.spec.ts', + workers: 1, + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], + }, }, ], }); diff --git a/frontend/tests/test-variant-console/quotas/quota-display.spec.ts b/frontend/tests/test-variant-console/quotas/quota-display.spec.ts new file mode 100644 index 0000000000..50bdc11363 --- /dev/null +++ b/frontend/tests/test-variant-console/quotas/quota-display.spec.ts @@ -0,0 +1,209 @@ +import { expect, test } from '@playwright/test'; + +import { createClientIdQuota, createUserQuota, deleteClientIdQuota, deleteUserQuota } from '../../shared/quota.utils'; +import { QuotaPage } from '../utils/quota-page'; + +test.describe('Quotas - Display and Data Verification', () => { + test('should display quota with all rate types configured', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaClientId = `display-all-rates-${timestamp}`; + + await test.step('Create quota with all rate types', async () => { + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 10_485_760, // 10 MiB + consumerByteRate: 5_242_880, // 5 MiB + controllerMutationRate: 100, + }); + }); + + await test.step('Navigate to quotas page', async () => { + await quotaPage.goToQuotasList(); + }); + + await test.step('Verify all rate values are displayed correctly', async () => { + await quotaPage.verifyQuotaExists(quotaClientId); + await quotaPage.verifyQuotaInTable(quotaClientId, 'client-id'); + + // Verify all three rate types are visible in the same row + const row = page.locator('tr').filter({ hasText: quotaClientId }); + await expect(row.locator('td').filter({ hasText: '10 MiB' })).toBeVisible(); + await expect(row.locator('td').filter({ hasText: '5 MiB' })).toBeVisible(); + await expect(row.locator('td').filter({ hasText: '100' })).toBeVisible(); + }); + + await test.step('Cleanup', async () => { + await deleteClientIdQuota(quotaClientId); + }); + }); + + test('should display quota with only producer rate configured', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaClientId = `producer-only-${timestamp}`; + + await test.step('Create quota with only producer rate', async () => { + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 20_971_520, // 20 MiB + }); + }); + + await test.step('Navigate and verify', async () => { + await quotaPage.goToQuotasList(); + await quotaPage.verifyQuotaExists(quotaClientId); + await quotaPage.verifyProducerRate('20 MiB', quotaClientId); + + // Verify consumer and controller rates show skip icon (not configured) + const row = page.locator('tr').filter({ hasText: quotaClientId }); + await expect(row.getByRole('cell').nth(2)).toContainText('20 MiB'); // Producer rate column + }); + + await test.step('Cleanup', async () => { + await deleteClientIdQuota(quotaClientId); + }); + }); + + test('should display quota with only consumer rate configured', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaClientId = `consumer-only-${timestamp}`; + + await test.step('Create quota with only consumer rate', async () => { + await createClientIdQuota({ + clientId: quotaClientId, + consumerByteRate: 15_728_640, // 15 MiB + }); + }); + + await test.step('Navigate and verify', async () => { + await quotaPage.goToQuotasList(); + await quotaPage.verifyQuotaExists(quotaClientId); + await quotaPage.verifyConsumerRate('15 MiB', quotaClientId); + }); + + await test.step('Cleanup', async () => { + await deleteClientIdQuota(quotaClientId); + }); + }); + + test('should handle quotas with large byte values correctly', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaClientId = `large-values-${timestamp}`; + + await test.step('Create quota with large byte values', async () => { + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 1_073_741_824, // 1 GiB + consumerByteRate: 2_147_483_648, // 2 GiB + }); + }); + + await test.step('Navigate and verify formatting', async () => { + await quotaPage.goToQuotasList(); + await quotaPage.verifyQuotaExists(quotaClientId); + + const row = page.locator('tr').filter({ hasText: quotaClientId }); + await expect(row.locator('td').filter({ hasText: '1 GiB' })).toBeVisible(); + await expect(row.locator('td').filter({ hasText: '2 GiB' })).toBeVisible(); + }); + + await test.step('Cleanup', async () => { + await deleteClientIdQuota(quotaClientId); + }); + }); + + test('should display quotas table with correct column headers', async ({ page }) => { + const quotaPage = new QuotaPage(page); + + await test.step('Navigate to quotas page', async () => { + await quotaPage.goToQuotasList(); + }); + + await test.step('Verify table column headers are present', async () => { + await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Producer Rate' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Consumer Rate' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Controller Mutation Rate' })).toBeVisible(); + }); + }); + + test('should reload page and maintain quota visibility', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaClientId = `reload-test-${timestamp}`; + + await test.step('Create quota', async () => { + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 8_388_608, // 8 MiB + }); + }); + + await test.step('Initial page load and verification', async () => { + await quotaPage.goToQuotasList(); + await quotaPage.verifyQuotaExists(quotaClientId); + await quotaPage.verifyProducerRate('8 MiB', quotaClientId); + }); + + await test.step('Reload page and verify quota still visible', async () => { + await quotaPage.reloadPage(); + await quotaPage.verifyQuotaExists(quotaClientId); + await quotaPage.verifyProducerRate('8 MiB', quotaClientId); + }); + + await test.step('Cleanup', async () => { + await deleteClientIdQuota(quotaClientId); + }); + }); + + test('should display multiple quotas with different entity types', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const clientQuota = `multi-type-client-${timestamp}`; + const userQuota = `multi-type-user-${timestamp}`; + + await test.step('Create quotas with different entity types', async () => { + await createClientIdQuota({ + clientId: clientQuota, + producerByteRate: 4_194_304, // 4 MiB + }); + + await createUserQuota({ + user: userQuota, + consumerByteRate: 6_291_456, // 6 MiB + }); + }); + + await test.step('Navigate and verify client-id quota', async () => { + await quotaPage.goToQuotasList(); + await quotaPage.verifyQuotaExists(clientQuota); + await quotaPage.verifyQuotaInTable(clientQuota, 'client-id'); + await quotaPage.verifyProducerRate('4 MiB', clientQuota); + }); + + await test.step('Cleanup', async () => { + await deleteClientIdQuota(clientQuota); + await deleteUserQuota(userQuota); + }); + }); + + test('should handle zero quotas scenario (empty table)', async ({ page }) => { + const quotaPage = new QuotaPage(page); + + await test.step('Navigate to quotas page', async () => { + await quotaPage.goToQuotasList(); + }); + + await test.step('Verify page loads without errors even if no quotas exist', async () => { + // Page should load successfully + await expect(page.getByRole('heading', { name: 'Quotas' })).toBeVisible(); + + // Table headers should still be visible + await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible(); + }); + }); +}); diff --git a/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts b/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts new file mode 100644 index 0000000000..79b636894e --- /dev/null +++ b/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts @@ -0,0 +1,320 @@ +import { expect, test } from '@playwright/test'; + +import { createClientIdQuota, deleteClientIdQuota } from '../../shared/quota.utils'; +import { QuotaPage } from '../utils/quota-page'; + +const DEFAULT_PAGE_SIZE = 50; + +// Regex patterns for pagination tests +const ENTITY_TYPE_REGEX = /client-id|user|ip/; +const PAGE_0_REGEX = /page=0/; +const PAGE_1_REGEX = /page=1/; + +test.describe('Quotas - Pagination', () => { + test('should not show pagination controls when quotas count is less than page size', async ({ page }) => { + await test.step('Navigate to quotas page', async () => { + await page.goto('/quotas'); + }); + + await test.step('Verify pagination is not visible for small datasets', async () => { + // Check if table has rows but pagination is not present + const rowCount = await page.locator('tr').filter({ hasText: ENTITY_TYPE_REGEX }).count(); + + // If there are less than 50 items, pagination should not be visible + if (rowCount < DEFAULT_PAGE_SIZE) { + const pagination = page.locator('[aria-label="pagination"]'); + await expect(pagination).not.toBeVisible(); + } + }); + }); + + test('should navigate to next page using pagination controls', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaIds: string[] = []; + const QUOTA_COUNT = 55; // More than one page + const PAGE_SIZE = 20; + + await test.step(`Create ${QUOTA_COUNT} quotas`, async () => { + for (let i = 1; i <= QUOTA_COUNT; i++) { + const quotaClientId = `page-nav-test-${timestamp}-${i.toString().padStart(3, '0')}`; + quotaIds.push(quotaClientId); + + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 2_097_152, // 2MB + }); + } + }); + + const page1Quotas: string[] = []; + + await test.step('Navigate to quotas page with explicit page size', async () => { + await page.goto(`/quotas?page=0&pageSize=${PAGE_SIZE}`); + }); + + await test.step('Wait for quotas to load and capture page 1 quotas', async () => { + await expect(async () => { + const visibleQuotaCount = await page + .locator('tr') + .filter({ hasText: `page-nav-test-${timestamp}` }) + .count(); + expect(visibleQuotaCount).toBeGreaterThan(0); + }).toPass({ timeout: 15_000, intervals: [500, 1000, 5000] }); + + // Capture which quotas are visible on page 1 + const rows = page.locator('tr').filter({ hasText: `page-nav-test-${timestamp}` }); + const rowCount = await rows.count(); + for (let i = 0; i < rowCount; i++) { + const text = await rows.nth(i).textContent(); + const match = text?.match(/page-nav-test-\d+-\d+/); + if (match) { + page1Quotas.push(match[0]); + } + } + expect(page1Quotas.length).toBeGreaterThan(0); + }); + + await test.step('Click next page button', async () => { + await quotaPage.clickNextPage(); + + // Wait for page navigation to complete + await page.waitForURL(PAGE_1_REGEX, { timeout: 5000 }); + }); + + await test.step('Verify page 1 quotas are no longer visible on page 2', async () => { + // At least one quota from page 1 should not be visible on page 2 + await quotaPage.verifyQuotaNotExists(page1Quotas[0]); + + // Verify we have different quotas on page 2 + const page2Rows = await page + .locator('tr') + .filter({ hasText: `page-nav-test-${timestamp}` }) + .count(); + expect(page2Rows).toBeGreaterThan(0); + }); + + await test.step('Cleanup: Delete all test quotas', async () => { + for (const quotaId of quotaIds) { + await deleteClientIdQuota(quotaId); + } + }); + }); + + test('should navigate back to previous page', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaIds: string[] = []; + const QUOTA_COUNT = 55; + const PAGE_SIZE = 20; + + await test.step(`Create ${QUOTA_COUNT} quotas`, async () => { + for (let i = 1; i <= QUOTA_COUNT; i++) { + const quotaClientId = `prev-page-test-${timestamp}-${i.toString().padStart(3, '0')}`; + quotaIds.push(quotaClientId); + + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 3_145_728, // 3MB + }); + } + }); + + const page1Quotas: string[] = []; + const page2Quotas: string[] = []; + + await test.step('Navigate to quotas page with explicit page size', async () => { + await page.goto(`/quotas?page=0&pageSize=${PAGE_SIZE}`); + }); + + await test.step('Wait for quotas to load and capture page 1 quotas', async () => { + await expect(async () => { + const visibleQuotaCount = await page + .locator('tr') + .filter({ hasText: `prev-page-test-${timestamp}` }) + .count(); + expect(visibleQuotaCount).toBeGreaterThan(0); + }).toPass({ timeout: 15_000, intervals: [500, 1000, 5000] }); + + // Capture which quotas are visible on page 1 + const rows = page.locator('tr').filter({ hasText: `prev-page-test-${timestamp}` }); + const rowCount = await rows.count(); + for (let i = 0; i < rowCount; i++) { + const text = await rows.nth(i).textContent(); + const match = text?.match(/prev-page-test-\d+-\d+/); + if (match) { + page1Quotas.push(match[0]); + } + } + expect(page1Quotas.length).toBeGreaterThan(0); + }); + + await test.step('Navigate to page 2', async () => { + await quotaPage.clickNextPage(); + await page.waitForURL(PAGE_1_REGEX, { timeout: 5000 }); + }); + + await test.step('Capture page 2 quotas', async () => { + // Capture which quotas are visible on page 2 + const rows = page.locator('tr').filter({ hasText: `prev-page-test-${timestamp}` }); + const rowCount = await rows.count(); + for (let i = 0; i < rowCount; i++) { + const text = await rows.nth(i).textContent(); + const match = text?.match(/prev-page-test-\d+-\d+/); + if (match) { + page2Quotas.push(match[0]); + } + } + expect(page2Quotas.length).toBeGreaterThan(0); + + // Verify page 1 quota is not on page 2 + await quotaPage.verifyQuotaNotExists(page1Quotas[0]); + }); + + await test.step('Navigate back to page 1', async () => { + await quotaPage.clickPreviousPage(); + await page.waitForURL(PAGE_0_REGEX, { timeout: 5000 }); + }); + + await test.step('Verify page 1 quotas are visible again', async () => { + await expect(async () => { + await quotaPage.verifyQuotaExists(page1Quotas[0]); + }).toPass({ timeout: 10_000 }); + + // Page 2 quota should not be visible on page 1 + await quotaPage.verifyQuotaNotExists(page2Quotas[0]); + }); + + await test.step('Cleanup: Delete all test quotas', async () => { + for (const quotaId of quotaIds) { + await deleteClientIdQuota(quotaId); + } + }); + }); + + test('should persist pagination state in URL', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaIds: string[] = []; + const QUOTA_COUNT = 60; + const PAGE_SIZE = 20; + + await test.step(`Create ${QUOTA_COUNT} quotas`, async () => { + for (let i = 1; i <= QUOTA_COUNT; i++) { + const quotaClientId = `url-state-test-${timestamp}-${i.toString().padStart(3, '0')}`; + quotaIds.push(quotaClientId); + + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 5_242_880, // 5MB + }); + } + }); + + const page1Quotas: string[] = []; + + await test.step('Navigate to quotas page with explicit page size', async () => { + await page.goto(`/quotas?page=0&pageSize=${PAGE_SIZE}`); + }); + + await test.step('Wait for quotas to load and capture page 1 quotas', async () => { + await expect(async () => { + const visibleQuotaCount = await page + .locator('tr') + .filter({ hasText: `url-state-test-${timestamp}` }) + .count(); + expect(visibleQuotaCount).toBeGreaterThan(0); + }).toPass({ timeout: 15_000, intervals: [500, 1000, 5000] }); + + // Capture which quotas are visible on page 1 + const rows = page.locator('tr').filter({ hasText: `url-state-test-${timestamp}` }); + const rowCount = await rows.count(); + for (let i = 0; i < rowCount; i++) { + const text = await rows.nth(i).textContent(); + const match = text?.match(/url-state-test-\d+-\d+/); + if (match) { + page1Quotas.push(match[0]); + } + } + expect(page1Quotas.length).toBeGreaterThan(0); + }); + + await test.step('Navigate to page 2', async () => { + await quotaPage.clickNextPage(); + await page.waitForURL(PAGE_1_REGEX, { timeout: 5000 }); + }); + + await test.step('Verify URL contains pagination state', () => { + const url = page.url(); + expect(url).toContain('page=1'); + expect(url).toContain(`pageSize=${PAGE_SIZE}`); + }); + + await test.step('Reload page and verify pagination state persists', async () => { + await page.reload(); + + // Should still be on page 2 + expect(page.url()).toContain('page=1'); + + // Page 1 quota should not be visible (we're on page 2) + await quotaPage.verifyQuotaNotExists(page1Quotas[0]); + }); + + await test.step('Cleanup: Delete all test quotas', async () => { + for (const quotaId of quotaIds) { + await deleteClientIdQuota(quotaId); + } + }); + }); + + test('should display correct page info (showing X-Y of Z)', async ({ page }) => { + const quotaPage = new QuotaPage(page); + const timestamp = Date.now(); + const quotaIds: string[] = []; + const QUOTA_COUNT = 55; + const PAGE_SIZE = 20; + + await test.step(`Create ${QUOTA_COUNT} quotas`, async () => { + for (let i = 1; i <= QUOTA_COUNT; i++) { + const quotaClientId = `page-info-test-${timestamp}-${i.toString().padStart(3, '0')}`; + quotaIds.push(quotaClientId); + + await createClientIdQuota({ + clientId: quotaClientId, + producerByteRate: 6_291_456, // 6MB + }); + } + }); + + await test.step('Navigate to quotas page with explicit page size', async () => { + await page.goto(`/quotas?page=0&pageSize=${PAGE_SIZE}`); + }); + + await test.step('Wait for quotas to load', async () => { + await expect(async () => { + const visibleQuotaCount = await page + .locator('tr') + .filter({ hasText: `page-info-test-${timestamp}` }) + .count(); + expect(visibleQuotaCount).toBeGreaterThan(0); + }).toPass({ timeout: 15_000, intervals: [500, 1000, 5000] }); + }); + + await test.step('Verify page info text is displayed', async () => { + // Look for text like "Page 1 of 3" or similar pagination info + const pageInfo = page.locator('text=/Page \\d+ of \\d+/'); + + const isVisible = await pageInfo.isVisible({ timeout: 2000 }).catch(() => false); + + if (isVisible) { + await expect(pageInfo).toBeVisible(); + } + }); + + await test.step('Cleanup: Delete all test quotas', async () => { + for (const quotaId of quotaIds) { + await deleteClientIdQuota(quotaId); + } + }); + }); +}); diff --git a/frontend/tests/test-variant-console/quotas/rpk-quota-creation.spec.ts b/frontend/tests/test-variant-console/quotas/rpk-quota-creation.spec.ts index 9ff46d4fa2..ce593f10c4 100644 --- a/frontend/tests/test-variant-console/quotas/rpk-quota-creation.spec.ts +++ b/frontend/tests/test-variant-console/quotas/rpk-quota-creation.spec.ts @@ -1,5 +1,6 @@ import { test } from '@playwright/test'; +import { sizeFactors } from '../../../src/utils/topic-utils'; import { createClientIdQuota, createUserQuota, deleteClientIdQuota, deleteUserQuota } from '../../shared/quota.utils'; import { QuotaPage } from '../utils/quota-page'; @@ -91,12 +92,12 @@ test.describe('Quotas - RPK Integration', () => { await createClientIdQuota({ clientId: quotaClient2, - consumerByteRate: 4_194_304, // 4MB + consumerByteRate: 4 * sizeFactors.MiB, }); await createUserQuota({ user: quotaUser1, - producerByteRate: 6_291_456, // 6MB + producerByteRate: 6 * sizeFactors.MiB, controllerMutationRate: 3, }); }); diff --git a/frontend/tests/test-variant-console/utils/quota-page.ts b/frontend/tests/test-variant-console/utils/quota-page.ts index b66788a673..6e31356be7 100644 --- a/frontend/tests/test-variant-console/utils/quota-page.ts +++ b/frontend/tests/test-variant-console/utils/quota-page.ts @@ -27,11 +27,6 @@ export class QuotaPage { await expect(this.page.getByText(entityName)).not.toBeVisible(); } - async verifyEntityType(entityType: 'client-id' | 'user' | 'ip') { - const cells = await this.page.locator('td').allTextContents(); - expect(cells).toContain(entityType); - } - async verifyProducerRate(rateValue: string, entityName?: string) { if (entityName) { // Find the row containing the entity name and verify the rate value is in that row @@ -51,15 +46,6 @@ export class QuotaPage { } } - async verifyControllerMutationRate(rateValue: string, entityName?: string) { - if (entityName) { - const row = this.page.locator('tr').filter({ hasText: entityName }); - await expect(row.locator('td').filter({ hasText: rateValue })).toBeVisible(); - } else { - await expect(this.page.getByText(rateValue)).toBeVisible(); - } - } - /** * Check if a specific entity name appears in the table */ @@ -77,4 +63,23 @@ export class QuotaPage { await this.page.goto(`${baseURL}/quotas`); await expect(this.page.getByRole('heading', { name: 'Quotas' })).toBeVisible(); } + + /** + * Pagination methods + */ + getNextPageButton() { + return this.page.locator('button[aria-label="Next Page"]'); + } + + getPreviousPageButton() { + return this.page.locator('button[aria-label="Previous Page"]'); + } + + async clickNextPage() { + await this.getNextPageButton().click(); + } + + async clickPreviousPage() { + await this.getPreviousPageButton().click(); + } }