diff --git a/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx b/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx
index 54954a894a..c103ad87cb 100644
--- a/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx
+++ b/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx
@@ -16,7 +16,7 @@ import { ListSecretsResponseSchema } from 'protogen/redpanda/api/console/v1alpha
import { listSecrets } from 'protogen/redpanda/api/console/v1alpha1/secret-SecretService_connectquery';
import { Scope, SecretSchema } from 'protogen/redpanda/api/dataplane/v1/secret_pb';
import React from 'react';
-import { MAX_PAGE_SIZE } from 'react-query/react-query.utils';
+import { SECRETS_LIST_PAGE_SIZE } from 'react-query/api/secret';
import { renderWithFileRoutes, screen, waitFor } from 'test-utils';
vi.mock('state/ui-state', () => ({
@@ -81,11 +81,71 @@ describe('SecretsStoreListPage', () => {
const callArgs = listSecretsMock.mock.calls[0];
expect(callArgs[0]).toMatchObject({
request: {
- pageSize: MAX_PAGE_SIZE,
+ pageSize: SECRETS_LIST_PAGE_SIZE,
+ pageToken: '',
},
});
});
+ test('follows nextPageToken until all secrets are returned', async () => {
+ const pageOne = [
+ create(SecretSchema, {
+ id: 'page1-first-secret',
+ labels: {},
+ scopes: [Scope.AI_GATEWAY],
+ }),
+ ];
+ const pageTwo = [
+ create(SecretSchema, {
+ id: 'page2-pgdb-dsn',
+ labels: {},
+ scopes: [Scope.AI_GATEWAY],
+ }),
+ ];
+
+ const listSecretsMock = vi.fn().mockImplementation(({ request }) => {
+ if (!request?.pageToken) {
+ return create(ListSecretsResponseSchema, {
+ response: { secrets: pageOne, nextPageToken: 'page2' },
+ });
+ }
+ return create(ListSecretsResponseSchema, {
+ response: { secrets: pageTwo, nextPageToken: '' },
+ });
+ });
+ const transport = createListSecretsTransport(listSecretsMock);
+
+ renderWithFileRoutes(, { transport });
+
+ expect(await screen.findByText('page1-first-secret')).toBeVisible();
+ expect(await screen.findByText('page2-pgdb-dsn')).toBeVisible();
+ expect(listSecretsMock).toHaveBeenCalledTimes(2);
+ expect(listSecretsMock.mock.calls[1][0]).toMatchObject({
+ request: { pageSize: SECRETS_LIST_PAGE_SIZE, pageToken: 'page2' },
+ });
+ });
+
+ test('stops and surfaces an error if the server returns a non-advancing pageToken', async () => {
+ const listSecretsMock = vi.fn().mockImplementation(() =>
+ create(ListSecretsResponseSchema, {
+ response: {
+ secrets: [create(SecretSchema, { id: 'looping-secret', scopes: [Scope.AI_GATEWAY] })],
+ nextPageToken: 'stuck',
+ },
+ })
+ );
+ const transport = createListSecretsTransport(listSecretsMock);
+
+ renderWithFileRoutes(, { transport });
+
+ await waitFor(() => {
+ expect(screen.getByText(/Error loading secrets:/i)).toBeVisible();
+ });
+ // Server returned nextPageToken='stuck' on first call; second call echoed 'stuck' again and the
+ // loop bailed out. Anything higher means the guard did not trip.
+ expect(listSecretsMock).toHaveBeenCalledTimes(2);
+ });
+
test('should display empty state when no secrets exist', async () => {
const listSecretsResponse = create(ListSecretsResponseSchema, {
response: {
diff --git a/frontend/src/react-query/api/secret.tsx b/frontend/src/react-query/api/secret.tsx
index 7b3b6d8d4e..dda3b166a5 100644
--- a/frontend/src/react-query/api/secret.tsx
+++ b/frontend/src/react-query/api/secret.tsx
@@ -1,13 +1,17 @@
import { create } from '@bufbuild/protobuf';
import type { GenMessage } from '@bufbuild/protobuf/codegenv1';
-import { createConnectQueryKey, useMutation, useQuery } from '@connectrpc/connect-query';
-import { useQueryClient } from '@tanstack/react-query';
+import {
+ callUnaryMethod,
+ createConnectQueryKey,
+ useQuery as useConnectQuery,
+ useMutation,
+ useTransport,
+} from '@connectrpc/connect-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
GetSecretRequestSchema,
type GetSecretResponse,
- type ListSecretsRequest,
ListSecretsRequestSchema,
- type ListSecretsResponse,
SecretService,
} from 'protogen/redpanda/api/console/v1alpha1/secret_pb';
import {
@@ -26,33 +30,70 @@ import {
ListSecretsFilterSchema,
type ListSecretsRequest as ListSecretsRequestDataPlane,
ListSecretsRequestSchema as ListSecretsRequestSchemaDataPlane,
+ type Secret,
} from 'protogen/redpanda/api/dataplane/v1/secret_pb';
import { listResources } from 'protogen/redpanda/api/dataplane/v1/secret-SecretService_connectquery';
-import { MAX_PAGE_SIZE, type MessageInit, type QueryOptions } from 'react-query/react-query.utils';
+import type { MessageInit, QueryOptions } from 'react-query/react-query.utils';
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
+// Matches the server-side upper bound declared in redpanda/api/dataplane/v1/secret.proto.
+export const SECRETS_LIST_PAGE_SIZE = 50;
+// Hard cap on pagination iterations. At SECRETS_LIST_PAGE_SIZE=50 this allows 10k secrets,
+// which is far beyond any realistic tenant. Protects against a misbehaving server returning
+// non-empty nextPageToken indefinitely.
+export const SECRETS_LIST_MAX_PAGES = 200;
+
export const useListSecretsQuery = (
input?: MessageInit,
- options?: QueryOptions, ListSecretsResponse>
+ options?: { enabled?: boolean }
) => {
- const listSecretsRequestDataPlane = create(ListSecretsRequestSchemaDataPlane, {
- pageSize: MAX_PAGE_SIZE,
- filter: input?.filter?.nameContains
- ? create(ListSecretsFilterSchema, {
- nameContains: input?.filter?.nameContains,
- })
- : undefined,
- });
-
- const listSecretsRequest = create(ListSecretsRequestSchema, {
- request: listSecretsRequestDataPlane,
- });
+ const transport = useTransport();
+ const nameContains = input?.filter?.nameContains;
- return useQuery(listSecrets, listSecretsRequest, {
+ return useQuery({
+ queryKey: [
+ ...createConnectQueryKey({
+ schema: SecretService.method.listSecrets,
+ cardinality: 'finite',
+ }),
+ { nameContains: nameContains ?? '' },
+ ],
enabled: options?.enabled,
- select: (data) => ({
- secrets: data.response?.secrets || [],
- }),
+ // Our queryFn throws intentionally on pagination safety violations (non-advancing token,
+ // max-pages exceeded). Retrying would multiply the server round-trips for no benefit.
+ retry: false,
+ queryFn: async ({ signal }) => {
+ const secrets: Secret[] = [];
+ let pageToken = '';
+ for (let iteration = 0; iteration < SECRETS_LIST_MAX_PAGES; iteration++) {
+ if (signal?.aborted) {
+ throw signal.reason ?? new Error('ListSecrets query aborted');
+ }
+ const request = create(ListSecretsRequestSchema, {
+ request: create(ListSecretsRequestSchemaDataPlane, {
+ pageSize: SECRETS_LIST_PAGE_SIZE,
+ pageToken,
+ filter: nameContains ? create(ListSecretsFilterSchema, { nameContains }) : undefined,
+ }),
+ });
+ const response = await callUnaryMethod(transport, listSecrets, request, { signal });
+ for (const secret of response.response?.secrets ?? []) {
+ if (secret) {
+ secrets.push(secret);
+ }
+ }
+ const next = response.response?.nextPageToken ?? '';
+ if (!next) {
+ return { secrets };
+ }
+ // Guard against a server that returns the same token twice — would otherwise loop forever.
+ if (next === pageToken) {
+ throw new Error('ListSecrets returned a non-advancing nextPageToken; aborting to avoid infinite loop');
+ }
+ pageToken = next;
+ }
+ throw new Error(`ListSecrets exceeded ${SECRETS_LIST_MAX_PAGES} pages; aborting to avoid runaway pagination`);
+ },
});
};
@@ -63,7 +104,7 @@ export const useGetSecretQuery = (
const getSecretRequestDataPlane = create(GetSecretRequestSchemaDataPlane, { id: input?.id });
const getSecretRequest = create(GetSecretRequestSchema, { request: getSecretRequestDataPlane });
- return useQuery(getSecret, getSecretRequest, { enabled: options?.enabled });
+ return useConnectQuery(getSecret, getSecretRequest, { enabled: options?.enabled });
};
export const useListResourcesForSecretQuery = (
@@ -73,7 +114,7 @@ export const useListResourcesForSecretQuery = (
const filter = create(ListResourcesRequest_FilterSchema, { secretId });
const request = create(ListResourcesRequestSchema, { filter });
- return useQuery(listResources, request, {
+ return useConnectQuery(listResources, request, {
enabled: !!secretId && options?.enabled !== false,
});
};