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 = {