Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
139 changes: 139 additions & 0 deletions frontend/src/components/pages/overview/overview.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="cluster-health-overview" />,
}));

vi.mock('./shadow-link-overview-card', () => ({
ShadowLinkSection: () => <div data-testid="shadow-link-section" />,
}));

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<ClusterOverview>) {
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(<Overview matchedPath="/overview" />);

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(<Overview matchedPath="/overview" />);

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(<Overview matchedPath="/overview" />);

const { left, right } = getSchemaRegistryCells();
expect(left).toHaveTextContent('Not configured');
expect(right).toBeEmptyDOMElement();
});
});
34 changes: 19 additions & 15 deletions frontend/src/components/pages/overview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,24 @@ function ClusterDetails() {
name: c.name,
status: formatStatus(c.status),
}));
const schemaRegistryFallback = overview.schemaRegistryError ? (
<Tooltip hasArrow label={overview.schemaRegistryError.message}>
<Text>Unavailable</Text>
</Tooltip>
) : (
'Not configured'
);
const schemaRegistryContent =
overview.schemaRegistry !== null
? [
[
formatStatus(overview.schemaRegistry.status),
overview.schemaRegistry?.status?.status === StatusType.HEALTHY
? `${overview.schemaRegistry.registeredSubjectsCount} schemas`
: undefined,
],
]
: [[schemaRegistryFallback]];

return (
<Grid alignItems="center" gap={2} templateColumns={{ base: 'auto', lg: 'repeat(3, auto)' }} w="full">
Expand All @@ -344,21 +362,7 @@ function ClusterDetails() {
content={hasConnect ? clusterLines.map((c) => [c.name, c.status]) : [['Not configured']]}
title="Kafka Connect"
/>
<Details
content={
overview.schemaRegistry !== null
? [
[
formatStatus(overview.schemaRegistry.status),
overview.schemaRegistry?.status?.status === StatusType.HEALTHY
? `${overview.schemaRegistry.registeredSubjectsCount} schemas`
: undefined,
],
]
: [['Not configured']]
}
title="Schema Registry"
/>
<Details content={schemaRegistryContent} title="Schema Registry" />
</DetailsBlock>

<DetailsBlock title="Storage">
Expand Down
185 changes: 185 additions & 0 deletions frontend/src/state/backend-api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading