diff --git a/frontend/src/components/pages/overview/overview.test.tsx b/frontend/src/components/pages/overview/overview.test.tsx new file mode 100644 index 0000000000..f80920bc6b --- /dev/null +++ b/frontend/src/components/pages/overview/overview.test.tsx @@ -0,0 +1,139 @@ +import { create } from '@bufbuild/protobuf'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { screen } from '@testing-library/react'; +import { + ComponentStatusSchema, + GetSchemaRegistryInfoResponseSchema, + StatusType, +} from 'protogen/redpanda/api/console/v1alpha1/cluster_status_pb'; +import type { ReactNode } from 'react'; +import { renderWithFileRoutes } from 'test-utils'; + +vi.mock('../../../state/app-global', () => ({ + appGlobal: { + onRefresh: null, + historyPush: vi.fn(), + historyReplace: vi.fn(), + }, +})); + +vi.mock('./cluster-health-overview', () => ({ + default: () =>
, +})); + +vi.mock('./shadow-link-overview-card', () => ({ + ShadowLinkSection: () =>
, +})); + +vi.mock('../../builder-io/nurture-panel', () => ({ + default: () => null, +})); + +vi.mock('../../license/overview-license-notification', () => ({ + OverviewLicenseNotification: () => null, +})); + +vi.mock('../../misc/null-fallback-boundary', () => ({ + NullFallbackBoundary: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import Overview from './overview'; +import { api, useApiStore } from '../../../state/backend-api'; +import type { ClusterOverview } from '../../../state/rest-interfaces'; + +const initialClusterOverview = { ...useApiStore.getState().clusterOverview }; + +function resetOverviewStore(clusterOverview: Partial) { + useApiStore.setState({ + brokers: [], + licenses: [], + clusterOverview: { + ...initialClusterOverview, + kafkaAuthorizerInfo: null, + kafkaAuthorizerError: null, + kafka: null, + redpanda: null, + console: null, + kafkaConnect: null, + schemaRegistry: null, + schemaRegistryError: null, + ...clusterOverview, + }, + }); +} + +function stubOverviewRefreshes() { + Object.assign(api, { + refreshCluster: vi.fn(), + refreshClusterOverview: vi.fn().mockResolvedValue(undefined), + refreshBrokers: vi.fn(), + refreshClusterHealth: vi.fn().mockResolvedValue(undefined), + refreshDebugBundleStatuses: vi.fn().mockResolvedValue(undefined), + listLicenses: vi.fn().mockResolvedValue(undefined), + }); +} + +function getSchemaRegistryCells() { + const titleCell = screen.getByRole('heading', { name: 'Schema Registry' }).parentElement; + if (!titleCell) { + throw new Error('Schema Registry title cell not found'); + } + + const left = titleCell.nextElementSibling; + const right = left?.nextElementSibling ?? null; + if (!(left && right)) { + throw new Error('Schema Registry cells not found'); + } + + return { + left, + right, + }; +} + +describe('Overview schema registry card', () => { + beforeEach(() => { + vi.clearAllMocks(); + stubOverviewRefreshes(); + }); + + test('shows schema count when schema registry info is available', () => { + resetOverviewStore({ + schemaRegistry: create(GetSchemaRegistryInfoResponseSchema, { + status: create(ComponentStatusSchema, { status: StatusType.HEALTHY }), + registeredSubjectsCount: 3, + }), + }); + + renderWithFileRoutes(); + + const { left, right } = getSchemaRegistryCells(); + expect(left).toHaveTextContent('Healthy'); + expect(right).toHaveTextContent('3 schemas'); + }); + + test('shows unavailable when schema registry request failed', () => { + resetOverviewStore({ + schemaRegistryError: new ConnectError('schema registry unavailable', Code.Unavailable), + }); + + renderWithFileRoutes(); + + const { left, right } = getSchemaRegistryCells(); + expect(left).toHaveTextContent('Unavailable'); + expect(right).toBeEmptyDOMElement(); + }); + + test('shows not configured only when schema registry is null and there is no error', () => { + resetOverviewStore({ + schemaRegistry: null, + schemaRegistryError: null, + }); + + renderWithFileRoutes(); + + const { left, right } = getSchemaRegistryCells(); + expect(left).toHaveTextContent('Not configured'); + expect(right).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/pages/overview/overview.tsx b/frontend/src/components/pages/overview/overview.tsx index 00a53c050f..60c72cec30 100644 --- a/frontend/src/components/pages/overview/overview.tsx +++ b/frontend/src/components/pages/overview/overview.tsx @@ -336,6 +336,24 @@ function ClusterDetails() { name: c.name, status: formatStatus(c.status), })); + const schemaRegistryFallback = overview.schemaRegistryError ? ( + + Unavailable + + ) : ( + 'Not configured' + ); + const schemaRegistryContent = + overview.schemaRegistry !== null + ? [ + [ + formatStatus(overview.schemaRegistry.status), + overview.schemaRegistry?.status?.status === StatusType.HEALTHY + ? `${overview.schemaRegistry.registeredSubjectsCount} schemas` + : undefined, + ], + ] + : [[schemaRegistryFallback]]; return ( @@ -344,21 +362,7 @@ function ClusterDetails() { content={hasConnect ? clusterLines.map((c) => [c.name, c.status]) : [['Not configured']]} title="Kafka Connect" /> -
+
diff --git a/frontend/src/state/backend-api.test.ts b/frontend/src/state/backend-api.test.ts new file mode 100644 index 0000000000..c545ee444d --- /dev/null +++ b/frontend/src/state/backend-api.test.ts @@ -0,0 +1,185 @@ +import { create } from '@bufbuild/protobuf'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb'; +import { + ComponentStatusSchema, + GetConsoleInfoResponseSchema, + GetKafkaAuthorizerInfoResponseSchema, + GetKafkaConnectInfoResponseSchema, + GetKafkaInfoResponseSchema, + GetRedpandaInfoResponseSchema, + GetSchemaRegistryInfoResponseSchema, + StatusType, +} from 'protogen/redpanda/api/console/v1alpha1/cluster_status_pb'; +import { Reason } from 'protogen/redpanda/api/dataplane/v1alpha1/error_pb'; + +const { mockClusterStatusClient } = vi.hoisted(() => ({ + mockClusterStatusClient: { + getKafkaAuthorizerInfo: vi.fn(), + getConsoleInfo: vi.fn(), + getKafkaInfo: vi.fn(), + getRedpandaInfo: vi.fn(), + getKafkaConnectInfo: vi.fn(), + getSchemaRegistryInfo: vi.fn(), + }, +})); + +vi.mock('../config', () => ({ + config: { + clusterStatusClient: mockClusterStatusClient, + restBasePath: '', + controlplaneUrl: '', + grpcBasePath: '', + assetsPath: '', + fetch: vi.fn(), + setSidebarItems: vi.fn(), + setBreadcrumbs: vi.fn(), + isServerless: false, + isAdpEnabled: false, + featureFlags: {}, + }, + isEmbedded: vi.fn(() => false), +})); + +import { api, useApiStore } from './backend-api'; + +const healthySchemaRegistryResponse = create(GetSchemaRegistryInfoResponseSchema, { + status: create(ComponentStatusSchema, { status: StatusType.HEALTHY }), + registeredSubjectsCount: 3, +}); + +const schemaRegistryNotConfiguredReason = `REASON_${Reason[Reason.FEATURE_NOT_CONFIGURED]}`; +const initialClusterOverview = { ...useApiStore.getState().clusterOverview }; + +function resetBaseClusterStatusMocks() { + mockClusterStatusClient.getKafkaAuthorizerInfo.mockResolvedValue(create(GetKafkaAuthorizerInfoResponseSchema, {})); + mockClusterStatusClient.getConsoleInfo.mockResolvedValue(create(GetConsoleInfoResponseSchema, {})); + mockClusterStatusClient.getKafkaInfo.mockResolvedValue(create(GetKafkaInfoResponseSchema, {})); + mockClusterStatusClient.getRedpandaInfo.mockResolvedValue(create(GetRedpandaInfoResponseSchema, {})); + mockClusterStatusClient.getKafkaConnectInfo.mockResolvedValue(create(GetKafkaConnectInfoResponseSchema, {})); + mockClusterStatusClient.getSchemaRegistryInfo.mockResolvedValue(healthySchemaRegistryResponse); +} + +function resetApiStore() { + useApiStore.setState({ + userData: undefined, + clusterOverview: { + ...initialClusterOverview, + kafkaAuthorizerInfo: null, + kafkaAuthorizerError: null, + kafka: null, + redpanda: null, + console: null, + kafkaConnect: null, + schemaRegistry: null, + schemaRegistryError: null, + }, + }); +} + +describe('api.refreshClusterOverview schema registry state', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + resetBaseClusterStatusMocks(); + resetApiStore(); + }); + + test('requests schema registry info when canViewSchemas is undefined', async () => { + await api.refreshClusterOverview(); + + expect(mockClusterStatusClient.getSchemaRegistryInfo).toHaveBeenCalledTimes(1); + }); + + test('stores schema registry info and clears stale errors on success', async () => { + const staleError = new ConnectError('stale', Code.Internal); + useApiStore.setState((state) => ({ + clusterOverview: { + ...state.clusterOverview, + schemaRegistryError: staleError, + }, + userData: { canViewSchemas: true } as any, + })); + + await api.refreshClusterOverview(); + + const { clusterOverview } = useApiStore.getState(); + expect(clusterOverview.schemaRegistry).toBe(healthySchemaRegistryResponse); + expect(clusterOverview.schemaRegistryError).toBeNull(); + }); + + test('treats schema registry Code.Unimplemented with feature-not-configured detail as not configured', async () => { + mockClusterStatusClient.getSchemaRegistryInfo.mockRejectedValue( + new ConnectError('not configured', Code.Unimplemented, undefined, [ + { + desc: ErrorInfoSchema, + value: create(ErrorInfoSchema, { + reason: schemaRegistryNotConfiguredReason, + }), + }, + ]) + ); + useApiStore.setState({ userData: { canViewSchemas: true } as any }); + + await api.refreshClusterOverview(); + + const { clusterOverview } = useApiStore.getState(); + expect(clusterOverview.schemaRegistry).toBeNull(); + expect(clusterOverview.schemaRegistryError).toBeNull(); + }); + + test('treats schema registry Code.Unimplemented without details as not configured', async () => { + mockClusterStatusClient.getSchemaRegistryInfo.mockRejectedValue( + new ConnectError('not configured', Code.Unimplemented) + ); + + await api.refreshClusterOverview(); + + const { clusterOverview } = useApiStore.getState(); + expect(clusterOverview.schemaRegistry).toBeNull(); + expect(clusterOverview.schemaRegistryError).toBeNull(); + }); + + test('stores schema registry errors for unavailable responses', async () => { + const unavailableError = new ConnectError('schema registry unavailable', Code.Unavailable); + mockClusterStatusClient.getSchemaRegistryInfo.mockRejectedValue(unavailableError); + useApiStore.setState({ userData: { canViewSchemas: true } as any }); + + await api.refreshClusterOverview(); + + const { clusterOverview } = useApiStore.getState(); + expect(clusterOverview.schemaRegistry).toBeNull(); + expect(clusterOverview.schemaRegistryError).toBe(unavailableError); + }); + + test('stores schema registry errors for internal responses', async () => { + const internalError = new ConnectError('schema registry internal error', Code.Internal); + mockClusterStatusClient.getSchemaRegistryInfo.mockRejectedValue(internalError); + useApiStore.setState({ userData: { canViewSchemas: true } as any }); + + await api.refreshClusterOverview(); + + const { clusterOverview } = useApiStore.getState(); + expect(clusterOverview.schemaRegistry).toBeNull(); + expect(clusterOverview.schemaRegistryError).toBe(internalError); + }); + + test('skips schema registry request only when canViewSchemas is explicitly false', async () => { + const staleError = new ConnectError('stale', Code.Internal); + useApiStore.setState((state) => ({ + userData: { canViewSchemas: false } as any, + clusterOverview: { + ...state.clusterOverview, + schemaRegistryError: staleError, + }, + })); + + await api.refreshClusterOverview(); + + expect(mockClusterStatusClient.getSchemaRegistryInfo).not.toHaveBeenCalled(); + + const { clusterOverview } = useApiStore.getState(); + expect(clusterOverview.schemaRegistry).toBeNull(); + expect(clusterOverview.schemaRegistryError).toBeNull(); + }); +}); diff --git a/frontend/src/state/backend-api.ts b/frontend/src/state/backend-api.ts index 174db831ec..556cecc9d4 100644 --- a/frontend/src/state/backend-api.ts +++ b/frontend/src/state/backend-api.ts @@ -12,8 +12,7 @@ /*eslint block-scoped-var: "error"*/ import { create, type Registry } from '@bufbuild/protobuf'; -import type { ConnectError } from '@connectrpc/connect'; -import { Code } from '@connectrpc/connect'; +import { Code, ConnectError } from '@connectrpc/connect'; import { createLinkedAbortController } from '@connectrpc/connect/protocol'; import { createStandaloneToast, redpandaTheme, redpandaToastOptions } from '@redpanda-data/ui'; import { @@ -122,6 +121,7 @@ import { uiState } from './ui-state'; import { config as appConfig, isEmbedded } from '../config'; import { addHeapEventProperties, trackHeapUser } from '../heap/heap.helper'; import { trackHubspotUser } from '../hubspot/hubspot.helper'; +import { ErrorInfoSchema } from '../protogen/google/rpc/error_details_pb'; import { AuthenticationMethod, type GetIdentityResponse, @@ -165,6 +165,7 @@ import { type Secret, type UpdateSecretRequest, } from '../protogen/redpanda/api/dataplane/v1/secret_pb'; +import { Reason } from '../protogen/redpanda/api/dataplane/v1alpha1/error_pb'; import type { KnowledgeBase, KnowledgeBaseCreate, @@ -294,6 +295,17 @@ function processVersionInfo(headers: Headers) { const _activeRequests: CacheEntry[] = []; const cache = new LazyMap((u) => new CacheEntry(u)); +const schemaRegistryNotConfiguredReason = `REASON_${Reason[Reason.FEATURE_NOT_CONFIGURED]}`; + +function isSchemaRegistryNotConfiguredError(error: unknown): error is ConnectError { + if (!(error instanceof ConnectError) || error.code !== Code.Unimplemented) { + return false; + } + + const reasons = error.findDetails(ErrorInfoSchema).map((info) => info.reason); + return reasons.length === 0 || reasons.includes(schemaRegistryNotConfiguredReason); +} + class CacheEntry { url: string; @@ -421,6 +433,7 @@ const _apiCreator = (set: any, get: any) => ({ console: null, kafkaConnect: null, schemaRegistry: null, + schemaRegistryError: null, } as ClusterOverview, brokers: null as BrokerWithConfigAndStorage[] | null, @@ -1073,12 +1086,22 @@ const _apiCreator = (set: any, get: any) => ({ }), ]; - // Conditionally add schema registry request - if (api.userData?.canViewSchemas) { + let schemaRegistryError: ConnectError | null = null; + + if (api.userData?.canViewSchemas !== false) { requests.push( - client.getSchemaRegistryInfo({}).catch((e) => { + client.getSchemaRegistryInfo({}).catch((error: unknown) => { + if (isSchemaRegistryNotConfiguredError(error)) { + return null; + } + + schemaRegistryError = + error instanceof ConnectError + ? error + : new ConnectError('Failed to fetch Schema Registry info', Code.Unknown, undefined, undefined, error); + // biome-ignore lint/suspicious/noConsole: intentional console usage - console.error(e); + console.error(error); return null; }) ); @@ -1108,6 +1131,7 @@ const _apiCreator = (set: any, get: any) => ({ redpanda: redpandaResponse as any, // biome-ignore lint/suspicious/noExplicitAny: gRPC response types from Promise.allSettled need explicit casting schemaRegistry: schemaRegistryResponse as any, + schemaRegistryError, // biome-ignore lint/suspicious/noExplicitAny: gRPC response types from Promise.allSettled need explicit casting kafkaConnect: kafkaConnectResponse as any, }, diff --git a/frontend/src/state/rest-interfaces.ts b/frontend/src/state/rest-interfaces.ts index 9f36fdce19..46f94002d3 100644 --- a/frontend/src/state/rest-interfaces.ts +++ b/frontend/src/state/rest-interfaces.ts @@ -1435,6 +1435,7 @@ export type ClusterOverview = { console: GetConsoleInfoResponse | null; kafkaConnect: GetKafkaConnectInfoResponse | null; schemaRegistry: GetSchemaRegistryInfoResponse | null; + schemaRegistryError?: ConnectError | null; }; export type OverviewStatus = {