chore: migrate clusters from toolkit to react-query (#2214)
This commit is contained in:
parent
0efbd130b9
commit
a4046d46ef
32 changed files with 274 additions and 572 deletions
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { Suspense, useCallback } from 'react';
|
||||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||||
import { GIT_TAG, GIT_COMMIT } from 'lib/constants';
|
import { GIT_TAG, GIT_COMMIT } from 'lib/constants';
|
||||||
import { clusterPath, getNonExactPath } from 'lib/paths';
|
import { clusterPath, getNonExactPath } from 'lib/paths';
|
||||||
|
@ -10,12 +10,6 @@ import Version from 'components/Version/Version';
|
||||||
import Alerts from 'components/Alerts/Alerts';
|
import Alerts from 'components/Alerts/Alerts';
|
||||||
import { ThemeProvider } from 'styled-components';
|
import { ThemeProvider } from 'styled-components';
|
||||||
import theme from 'theme/theme';
|
import theme from 'theme/theme';
|
||||||
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
|
||||||
import {
|
|
||||||
fetchClusters,
|
|
||||||
getClusterList,
|
|
||||||
getAreClustersFulfilled,
|
|
||||||
} from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
|
|
||||||
import * as S from './App.styled';
|
import * as S from './App.styled';
|
||||||
import Logo from './common/Logo/Logo';
|
import Logo from './common/Logo/Logo';
|
||||||
|
@ -23,9 +17,6 @@ import GitIcon from './common/Icons/GitIcon';
|
||||||
import DiscordIcon from './common/Icons/DiscordIcon';
|
import DiscordIcon from './common/Icons/DiscordIcon';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const areClustersFulfilled = useAppSelector(getAreClustersFulfilled);
|
|
||||||
const clusters = useAppSelector(getClusterList);
|
|
||||||
const [isSidebarVisible, setIsSidebarVisible] = React.useState(false);
|
const [isSidebarVisible, setIsSidebarVisible] = React.useState(false);
|
||||||
const onBurgerClick = () => setIsSidebarVisible(!isSidebarVisible);
|
const onBurgerClick = () => setIsSidebarVisible(!isSidebarVisible);
|
||||||
const closeSidebar = useCallback(() => setIsSidebarVisible(false), []);
|
const closeSidebar = useCallback(() => setIsSidebarVisible(false), []);
|
||||||
|
@ -35,10 +26,6 @@ const App: React.FC = () => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}, [location, closeSidebar]);
|
}, [location, closeSidebar]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
dispatch(fetchClusters());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<S.Layout>
|
<S.Layout>
|
||||||
|
@ -90,10 +77,9 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
<S.Container>
|
<S.Container>
|
||||||
<S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
|
<S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
|
||||||
<Nav
|
<Suspense fallback={<PageLoader />}>
|
||||||
clusters={clusters}
|
<Nav />
|
||||||
areClustersFulfilled={areClustersFulfilled}
|
</Suspense>
|
||||||
/>
|
|
||||||
</S.Sidebar>
|
</S.Sidebar>
|
||||||
<S.Overlay
|
<S.Overlay
|
||||||
$visible={isSidebarVisible}
|
$visible={isSidebarVisible}
|
||||||
|
@ -103,23 +89,19 @@ const App: React.FC = () => {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
aria-label="Overlay"
|
aria-label="Overlay"
|
||||||
/>
|
/>
|
||||||
{areClustersFulfilled ? (
|
<Routes>
|
||||||
<Routes>
|
{['/', '/ui', '/ui/clusters'].map((path) => (
|
||||||
{['/', '/ui', '/ui/clusters'].map((path) => (
|
|
||||||
<Route
|
|
||||||
key="Home" // optional: avoid full re-renders on route changes
|
|
||||||
path={path}
|
|
||||||
element={<Dashboard />}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Route
|
<Route
|
||||||
path={getNonExactPath(clusterPath())}
|
key="Home" // optional: avoid full re-renders on route changes
|
||||||
element={<ClusterPage />}
|
path={path}
|
||||||
|
element={<Dashboard />}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
))}
|
||||||
) : (
|
<Route
|
||||||
<PageLoader />
|
path={getNonExactPath(clusterPath())}
|
||||||
)}
|
element={<ClusterPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</S.Container>
|
</S.Container>
|
||||||
<S.AlertsContainer role="toolbar">
|
<S.AlertsContainer role="toolbar">
|
||||||
<Alerts />
|
<Alerts />
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { Routes, Navigate, Route, Outlet } from 'react-router-dom';
|
import { Routes, Navigate, Route, Outlet } from 'react-router-dom';
|
||||||
import useAppParams from 'lib/hooks/useAppParams';
|
import useAppParams from 'lib/hooks/useAppParams';
|
||||||
import { ClusterFeaturesEnum } from 'generated-sources';
|
import { ClusterFeaturesEnum } from 'generated-sources';
|
||||||
import {
|
|
||||||
getClustersFeatures,
|
|
||||||
getClustersReadonlyStatus,
|
|
||||||
} from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
import {
|
import {
|
||||||
clusterBrokerRelativePath,
|
clusterBrokerRelativePath,
|
||||||
clusterConnectorsRelativePath,
|
clusterConnectorsRelativePath,
|
||||||
|
@ -23,6 +18,7 @@ import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
|
||||||
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
|
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
|
||||||
import { BreadcrumbProvider } from 'components/common/Breadcrumb/Breadcrumb.provider';
|
import { BreadcrumbProvider } from 'components/common/Breadcrumb/Breadcrumb.provider';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
|
import useClusters from 'lib/hooks/api/useClusters';
|
||||||
|
|
||||||
const Brokers = React.lazy(() => import('components/Brokers/Brokers'));
|
const Brokers = React.lazy(() => import('components/Brokers/Brokers'));
|
||||||
const Topics = React.lazy(() => import('components/Topics/Topics'));
|
const Topics = React.lazy(() => import('components/Topics/Topics'));
|
||||||
|
@ -35,34 +31,25 @@ const ConsumerGroups = React.lazy(
|
||||||
|
|
||||||
const Cluster: React.FC = () => {
|
const Cluster: React.FC = () => {
|
||||||
const { clusterName } = useAppParams<ClusterNameRoute>();
|
const { clusterName } = useAppParams<ClusterNameRoute>();
|
||||||
const isReadOnly = useSelector(getClustersReadonlyStatus(clusterName));
|
const { data } = useClusters();
|
||||||
const features = useSelector(getClustersFeatures(clusterName));
|
const contextValue = React.useMemo(() => {
|
||||||
|
const cluster = data?.find(({ name }) => name === clusterName);
|
||||||
|
const features = cluster?.features || [];
|
||||||
|
|
||||||
const hasKafkaConnectConfigured = features.includes(
|
return {
|
||||||
ClusterFeaturesEnum.KAFKA_CONNECT
|
isReadOnly: cluster?.readOnly || false,
|
||||||
);
|
hasKafkaConnectConfigured: features.includes(
|
||||||
const hasSchemaRegistryConfigured = features.includes(
|
ClusterFeaturesEnum.KAFKA_CONNECT
|
||||||
ClusterFeaturesEnum.SCHEMA_REGISTRY
|
),
|
||||||
);
|
hasSchemaRegistryConfigured: features.includes(
|
||||||
const isTopicDeletionAllowed = features.includes(
|
ClusterFeaturesEnum.SCHEMA_REGISTRY
|
||||||
ClusterFeaturesEnum.TOPIC_DELETION
|
),
|
||||||
);
|
isTopicDeletionAllowed: features.includes(
|
||||||
const hasKsqlDbConfigured = features.includes(ClusterFeaturesEnum.KSQL_DB);
|
ClusterFeaturesEnum.TOPIC_DELETION
|
||||||
|
),
|
||||||
const contextValue = React.useMemo(
|
hasKsqlDbConfigured: features.includes(ClusterFeaturesEnum.KSQL_DB),
|
||||||
() => ({
|
};
|
||||||
isReadOnly,
|
}, [clusterName, data]);
|
||||||
hasKafkaConnectConfigured,
|
|
||||||
hasSchemaRegistryConfigured,
|
|
||||||
isTopicDeletionAllowed,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
hasKafkaConnectConfigured,
|
|
||||||
hasSchemaRegistryConfigured,
|
|
||||||
isReadOnly,
|
|
||||||
isTopicDeletionAllowed,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
|
@ -94,7 +81,7 @@ const Cluster: React.FC = () => {
|
||||||
</BreadcrumbRoute>
|
</BreadcrumbRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{hasSchemaRegistryConfigured && (
|
{contextValue.hasSchemaRegistryConfigured && (
|
||||||
<Route
|
<Route
|
||||||
path={getNonExactPath(clusterSchemasRelativePath)}
|
path={getNonExactPath(clusterSchemasRelativePath)}
|
||||||
element={
|
element={
|
||||||
|
@ -104,7 +91,7 @@ const Cluster: React.FC = () => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasKafkaConnectConfigured && (
|
{contextValue.hasKafkaConnectConfigured && (
|
||||||
<Route
|
<Route
|
||||||
path={getNonExactPath(clusterConnectsRelativePath)}
|
path={getNonExactPath(clusterConnectsRelativePath)}
|
||||||
element={
|
element={
|
||||||
|
@ -114,7 +101,7 @@ const Cluster: React.FC = () => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasKafkaConnectConfigured && (
|
{contextValue.hasKafkaConnectConfigured && (
|
||||||
<Route
|
<Route
|
||||||
path={getNonExactPath(clusterConnectorsRelativePath)}
|
path={getNonExactPath(clusterConnectorsRelativePath)}
|
||||||
element={
|
element={
|
||||||
|
@ -124,7 +111,7 @@ const Cluster: React.FC = () => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasKsqlDbConfigured && (
|
{contextValue.hasKsqlDbConfigured && (
|
||||||
<Route
|
<Route
|
||||||
path={getNonExactPath(clusterKsqlDbRelativePath)}
|
path={getNonExactPath(clusterKsqlDbRelativePath)}
|
||||||
element={
|
element={
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ClusterFeaturesEnum } from 'generated-sources';
|
import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
|
||||||
import { store } from 'redux/store';
|
import ClusterComponent from 'components/Cluster/Cluster';
|
||||||
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import Cluster from 'components/Cluster/Cluster';
|
|
||||||
import { fetchClusters } from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
import { screen } from '@testing-library/react';
|
|
||||||
import { render, WithRoute } from 'lib/testHelpers';
|
import { render, WithRoute } from 'lib/testHelpers';
|
||||||
import {
|
import {
|
||||||
clusterBrokersPath,
|
clusterBrokersPath,
|
||||||
|
clusterConnectorsPath,
|
||||||
clusterConnectsPath,
|
clusterConnectsPath,
|
||||||
clusterConsumerGroupsPath,
|
clusterConsumerGroupsPath,
|
||||||
clusterKsqlDbPath,
|
clusterKsqlDbPath,
|
||||||
|
@ -16,6 +14,9 @@ import {
|
||||||
clusterTopicsPath,
|
clusterTopicsPath,
|
||||||
} from 'lib/paths';
|
} from 'lib/paths';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import fetchMock from 'fetch-mock';
|
||||||
|
|
||||||
|
import { onlineClusterPayload } from './fixtures';
|
||||||
|
|
||||||
const CLusterCompText = {
|
const CLusterCompText = {
|
||||||
Topics: 'Topics',
|
Topics: 'Topics',
|
||||||
|
@ -46,98 +47,79 @@ jest.mock('components/KsqlDb/KsqlDb', () => () => (
|
||||||
));
|
));
|
||||||
|
|
||||||
describe('Cluster', () => {
|
describe('Cluster', () => {
|
||||||
const renderComponent = (pathname: string) => {
|
afterEach(() => fetchMock.restore());
|
||||||
render(
|
|
||||||
<WithRoute path={`${clusterPath()}/*`}>
|
const renderComponent = async (pathname: string, payload: Cluster[] = []) => {
|
||||||
<Cluster />
|
const mock = fetchMock.get('/api/clusters', payload);
|
||||||
</WithRoute>,
|
await act(() => {
|
||||||
{ initialEntries: [pathname], store }
|
render(
|
||||||
);
|
<WithRoute path={`${clusterPath()}/*`}>
|
||||||
|
<ClusterComponent />
|
||||||
|
</WithRoute>,
|
||||||
|
{ initialEntries: [pathname] }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return waitFor(() => expect(mock.called()).toBeTruthy());
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders Brokers', async () => {
|
it('renders Brokers', async () => {
|
||||||
await act(() => renderComponent(clusterBrokersPath('second')));
|
await renderComponent(clusterBrokersPath('second'));
|
||||||
expect(screen.getByText(CLusterCompText.Brokers)).toBeInTheDocument();
|
expect(screen.getByText(CLusterCompText.Brokers)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('renders Topics', async () => {
|
it('renders Topics', async () => {
|
||||||
await act(() => renderComponent(clusterTopicsPath('second')));
|
await renderComponent(clusterTopicsPath('second'));
|
||||||
expect(screen.getByText(CLusterCompText.Topics)).toBeInTheDocument();
|
expect(screen.getByText(CLusterCompText.Topics)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('renders ConsumerGroups', async () => {
|
it('renders ConsumerGroups', async () => {
|
||||||
await act(() => renderComponent(clusterConsumerGroupsPath('second')));
|
await renderComponent(clusterConsumerGroupsPath('second'));
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(CLusterCompText.ConsumerGroups)
|
screen.getByText(CLusterCompText.ConsumerGroups)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('configured features', () => {
|
describe('configured features', () => {
|
||||||
it('does not render Schemas if SCHEMA_REGISTRY is not configured', async () => {
|
const itCorrectlyHandlesConfiguredSchema = (
|
||||||
store.dispatch(
|
feature: ClusterFeaturesEnum,
|
||||||
fetchClusters.fulfilled(
|
text: string,
|
||||||
[
|
path: string
|
||||||
{
|
) => {
|
||||||
...onlineClusterPayload,
|
it(`renders Schemas if ${feature} is configured`, async () => {
|
||||||
features: [],
|
await renderComponent(path, [
|
||||||
},
|
{
|
||||||
],
|
...onlineClusterPayload,
|
||||||
'123'
|
features: [feature],
|
||||||
)
|
},
|
||||||
);
|
]);
|
||||||
await act(() => renderComponent(clusterSchemasPath('second')));
|
expect(screen.getByText(text)).toBeInTheDocument();
|
||||||
expect(
|
});
|
||||||
screen.queryByText(CLusterCompText.Schemas)
|
|
||||||
).not.toBeInTheDocument();
|
it(`does not render Schemas if ${feature} is not configured`, async () => {
|
||||||
});
|
await renderComponent(path, [
|
||||||
it('renders Schemas if SCHEMA_REGISTRY is configured', async () => {
|
{ ...onlineClusterPayload, features: [] },
|
||||||
store.dispatch(
|
]);
|
||||||
fetchClusters.fulfilled(
|
expect(screen.queryByText(text)).not.toBeInTheDocument();
|
||||||
[
|
});
|
||||||
{
|
};
|
||||||
...onlineClusterPayload,
|
|
||||||
features: [ClusterFeaturesEnum.SCHEMA_REGISTRY],
|
itCorrectlyHandlesConfiguredSchema(
|
||||||
},
|
ClusterFeaturesEnum.SCHEMA_REGISTRY,
|
||||||
],
|
CLusterCompText.Schemas,
|
||||||
'123'
|
clusterSchemasPath(onlineClusterPayload.name)
|
||||||
)
|
);
|
||||||
);
|
itCorrectlyHandlesConfiguredSchema(
|
||||||
await act(() =>
|
ClusterFeaturesEnum.KAFKA_CONNECT,
|
||||||
renderComponent(clusterSchemasPath(onlineClusterPayload.name))
|
CLusterCompText.Connect,
|
||||||
);
|
clusterConnectsPath(onlineClusterPayload.name)
|
||||||
expect(screen.getByText(CLusterCompText.Schemas)).toBeInTheDocument();
|
);
|
||||||
});
|
itCorrectlyHandlesConfiguredSchema(
|
||||||
it('renders Connect if KAFKA_CONNECT is configured', async () => {
|
ClusterFeaturesEnum.KAFKA_CONNECT,
|
||||||
store.dispatch(
|
CLusterCompText.Connect,
|
||||||
fetchClusters.fulfilled(
|
clusterConnectorsPath(onlineClusterPayload.name)
|
||||||
[
|
);
|
||||||
{
|
itCorrectlyHandlesConfiguredSchema(
|
||||||
...onlineClusterPayload,
|
ClusterFeaturesEnum.KSQL_DB,
|
||||||
features: [ClusterFeaturesEnum.KAFKA_CONNECT],
|
CLusterCompText.KsqlDb,
|
||||||
},
|
clusterKsqlDbPath(onlineClusterPayload.name)
|
||||||
],
|
);
|
||||||
'requestId'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await act(() =>
|
|
||||||
renderComponent(clusterConnectsPath(onlineClusterPayload.name))
|
|
||||||
);
|
|
||||||
expect(screen.getByText(CLusterCompText.Connect)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('renders KSQL if KSQL_DB is configured', async () => {
|
|
||||||
store.dispatch(
|
|
||||||
fetchClusters.fulfilled(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
...onlineClusterPayload,
|
|
||||||
features: [ClusterFeaturesEnum.KSQL_DB],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'requestId'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await act(() =>
|
|
||||||
renderComponent(clusterKsqlDbPath(onlineClusterPayload.name))
|
|
||||||
);
|
|
||||||
expect(screen.getByText(CLusterCompText.KsqlDb)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,7 @@ export const onlineClusterPayload: Cluster = {
|
||||||
topicCount: 3,
|
topicCount: 3,
|
||||||
bytesInPerSec: 1.55,
|
bytesInPerSec: 1.55,
|
||||||
bytesOutPerSec: 9.314,
|
bytesOutPerSec: 9.314,
|
||||||
|
readOnly: false,
|
||||||
features: [],
|
features: [],
|
||||||
};
|
};
|
||||||
export const offlineClusterPayload: Cluster = {
|
export const offlineClusterPayload: Cluster = {
|
||||||
|
@ -21,6 +22,7 @@ export const offlineClusterPayload: Cluster = {
|
||||||
bytesInPerSec: 3.42,
|
bytesInPerSec: 3.42,
|
||||||
bytesOutPerSec: 4.14,
|
bytesOutPerSec: 4.14,
|
||||||
features: [],
|
features: [],
|
||||||
|
readOnly: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clustersPayload: Cluster[] = [
|
export const clustersPayload: Cluster[] = [
|
|
@ -14,7 +14,7 @@ import * as Metrics from 'components/common/Metrics';
|
||||||
import { Tag } from 'components/common/Tag/Tag.styled';
|
import { Tag } from 'components/common/Tag/Tag.styled';
|
||||||
import Dropdown from 'components/common/Dropdown/Dropdown';
|
import Dropdown from 'components/common/Dropdown/Dropdown';
|
||||||
import DropdownItem from 'components/common/Dropdown/DropdownItem';
|
import DropdownItem from 'components/common/Dropdown/DropdownItem';
|
||||||
import { groupBy } from 'lodash';
|
import groupBy from 'lodash/groupBy';
|
||||||
import { Table } from 'components/common/table/Table/Table.styled';
|
import { Table } from 'components/common/table/Table/Table.styled';
|
||||||
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
||||||
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
||||||
|
|
|
@ -12,7 +12,7 @@ import MultiSelect from 'react-multi-select-component';
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { groupBy } from 'lodash';
|
import groupBy from 'lodash/groupBy';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
import { ErrorMessage } from '@hookform/error-message';
|
import { ErrorMessage } from '@hookform/error-message';
|
||||||
import Select from 'components/common/Select/Select';
|
import Select from 'components/common/Select/Select';
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { chunk } from 'lodash';
|
|
||||||
import * as Metrics from 'components/common/Metrics';
|
import * as Metrics from 'components/common/Metrics';
|
||||||
import { Cluster } from 'generated-sources';
|
|
||||||
import { Tag } from 'components/common/Tag/Tag.styled';
|
import { Tag } from 'components/common/Tag/Tag.styled';
|
||||||
import { Table } from 'components/common/table/Table/Table.styled';
|
import { Table } from 'components/common/table/Table/Table.styled';
|
||||||
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
|
||||||
|
@ -9,41 +7,38 @@ import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { clusterTopicsPath } from 'lib/paths';
|
import { clusterTopicsPath } from 'lib/paths';
|
||||||
import Switch from 'components/common/Switch/Switch';
|
import Switch from 'components/common/Switch/Switch';
|
||||||
|
import useClusters from 'lib/hooks/api/useClusters';
|
||||||
|
import { ServerStatus } from 'generated-sources';
|
||||||
|
|
||||||
import * as S from './ClustersWidget.styled';
|
import * as S from './ClustersWidget.styled';
|
||||||
|
|
||||||
interface Props {
|
const ClustersWidget: React.FC = () => {
|
||||||
clusters: Cluster[];
|
const { data } = useClusters();
|
||||||
onlineClusters: Cluster[];
|
|
||||||
offlineClusters: Cluster[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ClustersWidget: React.FC<Props> = ({
|
|
||||||
clusters,
|
|
||||||
onlineClusters,
|
|
||||||
offlineClusters,
|
|
||||||
}) => {
|
|
||||||
const [showOfflineOnly, setShowOfflineOnly] = React.useState<boolean>(false);
|
const [showOfflineOnly, setShowOfflineOnly] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const clusterList = React.useMemo(() => {
|
const config = React.useMemo(() => {
|
||||||
if (showOfflineOnly) {
|
const clusters = data || [];
|
||||||
return chunk(offlineClusters, 2);
|
const offlineClusters = clusters.filter(
|
||||||
}
|
({ status }) => status === ServerStatus.OFFLINE
|
||||||
return chunk(clusters, 2);
|
);
|
||||||
}, [clusters, offlineClusters, showOfflineOnly]);
|
return {
|
||||||
|
list: showOfflineOnly ? offlineClusters : clusters,
|
||||||
|
online: clusters.length - offlineClusters.length,
|
||||||
|
offline: offlineClusters.length,
|
||||||
|
};
|
||||||
|
}, [data, showOfflineOnly]);
|
||||||
|
|
||||||
const handleSwitch = () => setShowOfflineOnly(!showOfflineOnly);
|
const handleSwitch = () => setShowOfflineOnly(!showOfflineOnly);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Metrics.Wrapper>
|
<Metrics.Wrapper>
|
||||||
<Metrics.Section>
|
<Metrics.Section>
|
||||||
<Metrics.Indicator label={<Tag color="green">Online</Tag>}>
|
<Metrics.Indicator label={<Tag color="green">Online</Tag>}>
|
||||||
<span>{onlineClusters.length}</span>{' '}
|
<span>{config.online}</span>{' '}
|
||||||
<Metrics.LightText>clusters</Metrics.LightText>
|
<Metrics.LightText>clusters</Metrics.LightText>
|
||||||
</Metrics.Indicator>
|
</Metrics.Indicator>
|
||||||
<Metrics.Indicator label={<Tag color="gray">Offline</Tag>}>
|
<Metrics.Indicator label={<Tag color="gray">Offline</Tag>}>
|
||||||
<span>{offlineClusters.length}</span>{' '}
|
<span>{config.offline}</span>{' '}
|
||||||
<Metrics.LightText>clusters</Metrics.LightText>
|
<Metrics.LightText>clusters</Metrics.LightText>
|
||||||
</Metrics.Indicator>
|
</Metrics.Indicator>
|
||||||
</Metrics.Section>
|
</Metrics.Section>
|
||||||
|
@ -56,47 +51,45 @@ const ClustersWidget: React.FC<Props> = ({
|
||||||
/>
|
/>
|
||||||
<label>Only offline clusters</label>
|
<label>Only offline clusters</label>
|
||||||
</S.SwitchWrapper>
|
</S.SwitchWrapper>
|
||||||
{clusterList.map((chunkItem) => (
|
<Table isFullwidth>
|
||||||
<Table key={chunkItem.map(({ name }) => name).join('-')} isFullwidth>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<TableHeaderCell title="Cluster name" />
|
||||||
<TableHeaderCell title="Cluster name" />
|
<TableHeaderCell title="Version" />
|
||||||
<TableHeaderCell title="Version" />
|
<TableHeaderCell title="Brokers count" />
|
||||||
<TableHeaderCell title="Brokers count" />
|
<TableHeaderCell title="Partitions" />
|
||||||
<TableHeaderCell title="Partitions" />
|
<TableHeaderCell title="Topics" />
|
||||||
<TableHeaderCell title="Topics" />
|
<TableHeaderCell title="Production" />
|
||||||
<TableHeaderCell title="Production" />
|
<TableHeaderCell title="Consumption" />
|
||||||
<TableHeaderCell title="Consumption" />
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{config.list.map((cluster) => (
|
||||||
|
<tr key={cluster.name}>
|
||||||
|
<S.TableCell maxWidth="99px" width="350">
|
||||||
|
{cluster.readOnly && <Tag color="blue">readonly</Tag>}{' '}
|
||||||
|
{cluster.name}
|
||||||
|
</S.TableCell>
|
||||||
|
<S.TableCell maxWidth="99px">{cluster.version}</S.TableCell>
|
||||||
|
<S.TableCell maxWidth="99px">{cluster.brokerCount}</S.TableCell>
|
||||||
|
<S.TableCell maxWidth="78px">
|
||||||
|
{cluster.onlinePartitionCount}
|
||||||
|
</S.TableCell>
|
||||||
|
<S.TableCell maxWidth="60px">
|
||||||
|
<NavLink to={clusterTopicsPath(cluster.name)}>
|
||||||
|
{cluster.topicCount}
|
||||||
|
</NavLink>
|
||||||
|
</S.TableCell>
|
||||||
|
<S.TableCell maxWidth="85px">
|
||||||
|
<BytesFormatted value={cluster.bytesInPerSec} />
|
||||||
|
</S.TableCell>
|
||||||
|
<S.TableCell maxWidth="85px">
|
||||||
|
<BytesFormatted value={cluster.bytesOutPerSec} />
|
||||||
|
</S.TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{chunkItem.map((cluster) => (
|
</Table>
|
||||||
<tr key={cluster.name}>
|
|
||||||
<S.TableCell maxWidth="99px" width="350">
|
|
||||||
{cluster.readOnly && <Tag color="blue">readonly</Tag>}{' '}
|
|
||||||
{cluster.name}
|
|
||||||
</S.TableCell>
|
|
||||||
<S.TableCell maxWidth="99px">{cluster.version}</S.TableCell>
|
|
||||||
<S.TableCell maxWidth="99px">{cluster.brokerCount}</S.TableCell>
|
|
||||||
<S.TableCell maxWidth="78px">
|
|
||||||
{cluster.onlinePartitionCount}
|
|
||||||
</S.TableCell>
|
|
||||||
<S.TableCell maxWidth="60px">
|
|
||||||
<NavLink to={clusterTopicsPath(cluster.name)}>
|
|
||||||
{cluster.topicCount}
|
|
||||||
</NavLink>
|
|
||||||
</S.TableCell>
|
|
||||||
<S.TableCell maxWidth="85px">
|
|
||||||
<BytesFormatted value={cluster.bytesInPerSec} />
|
|
||||||
</S.TableCell>
|
|
||||||
<S.TableCell maxWidth="85px">
|
|
||||||
<BytesFormatted value={cluster.bytesOutPerSec} />
|
|
||||||
</S.TableCell>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import {
|
|
||||||
getClusterList,
|
|
||||||
getOnlineClusters,
|
|
||||||
getOfflineClusters,
|
|
||||||
} from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
import { RootState } from 'redux/interfaces';
|
|
||||||
|
|
||||||
import ClustersWidget from './ClustersWidget';
|
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState) => ({
|
|
||||||
clusters: getClusterList(state),
|
|
||||||
onlineClusters: getOnlineClusters(state),
|
|
||||||
offlineClusters: getOfflineClusters(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(ClustersWidget);
|
|
|
@ -1,22 +1,22 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { screen } from '@testing-library/react';
|
import { act, screen, waitFor } from '@testing-library/react';
|
||||||
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
|
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
|
import fetchMock from 'fetch-mock';
|
||||||
import { offlineCluster, onlineCluster, clusters } from './fixtures';
|
import { clustersPayload } from 'components/Cluster/__tests__/fixtures';
|
||||||
|
|
||||||
const setupComponent = () =>
|
|
||||||
render(
|
|
||||||
<ClustersWidget
|
|
||||||
clusters={clusters}
|
|
||||||
onlineClusters={[onlineCluster]}
|
|
||||||
offlineClusters={[offlineCluster]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('ClustersWidget', () => {
|
describe('ClustersWidget', () => {
|
||||||
beforeEach(() => setupComponent());
|
afterEach(() => fetchMock.restore());
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mock = fetchMock.get('/api/clusters', clustersPayload);
|
||||||
|
|
||||||
|
await act(() => {
|
||||||
|
render(<ClustersWidget />);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(mock.called()).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
it('renders clusterWidget list', () => {
|
it('renders clusterWidget list', () => {
|
||||||
expect(screen.getAllByRole('row').length).toBe(3);
|
expect(screen.getAllByRole('row').length).toBe(3);
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
|
|
||||||
import { getByTextContent, render } from 'lib/testHelpers';
|
|
||||||
|
|
||||||
describe('ClustersWidgetContainer', () => {
|
|
||||||
it('renders ClustersWidget', () => {
|
|
||||||
render(
|
|
||||||
<ClustersWidget clusters={[]} onlineClusters={[]} offlineClusters={[]} />
|
|
||||||
);
|
|
||||||
expect(getByTextContent('Online 0 clusters')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { Cluster, ServerStatus } from 'generated-sources';
|
|
||||||
|
|
||||||
export const onlineCluster: Cluster = {
|
|
||||||
name: 'secondLocal',
|
|
||||||
defaultCluster: false,
|
|
||||||
status: ServerStatus.ONLINE,
|
|
||||||
brokerCount: 1,
|
|
||||||
onlinePartitionCount: 6,
|
|
||||||
topicCount: 3,
|
|
||||||
bytesInPerSec: 0.00003061819685376471,
|
|
||||||
bytesOutPerSec: 5.737800890036267,
|
|
||||||
readOnly: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const offlineCluster: Cluster = {
|
|
||||||
name: 'local',
|
|
||||||
defaultCluster: true,
|
|
||||||
status: ServerStatus.OFFLINE,
|
|
||||||
brokerCount: 1,
|
|
||||||
onlinePartitionCount: 2,
|
|
||||||
topicCount: 2,
|
|
||||||
bytesInPerSec: 8000.00000673768,
|
|
||||||
bytesOutPerSec: 0.8153063567297119,
|
|
||||||
readOnly: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clusters: Cluster[] = [onlineCluster, offlineCluster];
|
|
|
@ -1,12 +1,13 @@
|
||||||
import React from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
|
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
|
||||||
import ClustersWidgetContainer from './ClustersWidget/ClustersWidgetContainer';
|
|
||||||
|
|
||||||
const Dashboard: React.FC = () => (
|
const Dashboard: React.FC = () => (
|
||||||
<>
|
<>
|
||||||
<PageHeading text="Dashboard" />
|
<PageHeading text="Dashboard" />
|
||||||
<ClustersWidgetContainer />
|
<Suspense>
|
||||||
|
<ClustersWidget />
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -3,17 +3,14 @@ import Dashboard from 'components/Dashboard/Dashboard';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
import { screen } from '@testing-library/dom';
|
import { screen } from '@testing-library/dom';
|
||||||
|
|
||||||
jest.mock(
|
jest.mock('components/Dashboard/ClustersWidget/ClustersWidget', () => () => (
|
||||||
'components/Dashboard/ClustersWidget/ClustersWidgetContainer.ts',
|
<div>mock-ClustersWidget</div>
|
||||||
() => () => <div>mock-ClustersWidgetContainer</div>
|
));
|
||||||
);
|
|
||||||
|
|
||||||
describe('Dashboard', () => {
|
describe('Dashboard', () => {
|
||||||
it('renders ClustersWidget', () => {
|
it('renders ClustersWidget', () => {
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
expect(
|
expect(screen.getByText('mock-ClustersWidget')).toBeInTheDocument();
|
||||||
screen.getByText('mock-ClustersWidgetContainer')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { getKsqlExecution } from 'redux/reducers/ksqlDb/selectors';
|
||||||
import { BASE_PARAMS } from 'lib/constants';
|
import { BASE_PARAMS } from 'lib/constants';
|
||||||
import { KsqlResponse, KsqlTableResponse } from 'generated-sources';
|
import { KsqlResponse, KsqlTableResponse } from 'generated-sources';
|
||||||
import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
|
import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
|
||||||
import { now } from 'lodash';
|
import now from 'lodash/now';
|
||||||
import { ClusterNameRoute } from 'lib/paths';
|
import { ClusterNameRoute } from 'lib/paths';
|
||||||
|
|
||||||
import type { FormValues } from './QueryForm/QueryForm';
|
import type { FormValues } from './QueryForm/QueryForm';
|
||||||
|
|
|
@ -1,30 +1,31 @@
|
||||||
|
import useClusters from 'lib/hooks/api/useClusters';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Cluster } from 'generated-sources';
|
|
||||||
|
|
||||||
import ClusterMenu from './ClusterMenu';
|
import ClusterMenu from './ClusterMenu';
|
||||||
import ClusterMenuItem from './ClusterMenuItem';
|
import ClusterMenuItem from './ClusterMenuItem';
|
||||||
import * as S from './Nav.styled';
|
import * as S from './Nav.styled';
|
||||||
|
|
||||||
interface Props {
|
const Nav: React.FC = () => {
|
||||||
areClustersFulfilled?: boolean;
|
const query = useClusters();
|
||||||
clusters: Cluster[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Nav: React.FC<Props> = ({ areClustersFulfilled, clusters }) => (
|
if (!query.isSuccess) {
|
||||||
<aside aria-label="Sidebar Menu">
|
return null;
|
||||||
<S.List>
|
}
|
||||||
<ClusterMenuItem to="/" title="Dashboard" isTopLevel />
|
|
||||||
</S.List>
|
|
||||||
|
|
||||||
{areClustersFulfilled &&
|
return (
|
||||||
clusters.map((cluster) => (
|
<aside aria-label="Sidebar Menu">
|
||||||
|
<S.List>
|
||||||
|
<ClusterMenuItem to="/" title="Dashboard" isTopLevel />
|
||||||
|
</S.List>
|
||||||
|
{query.data.map((cluster) => (
|
||||||
<ClusterMenu
|
<ClusterMenu
|
||||||
cluster={cluster}
|
cluster={cluster}
|
||||||
key={cluster.name}
|
key={cluster.name}
|
||||||
singleMode={clusters.length === 1}
|
singleMode={query.data.length === 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Nav;
|
export default Nav;
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
|
import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
|
||||||
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures';
|
|
||||||
import ClusterMenu from 'components/Nav/ClusterMenu';
|
import ClusterMenu from 'components/Nav/ClusterMenu';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { clusterConnectorsPath } from 'lib/paths';
|
import { clusterConnectorsPath } from 'lib/paths';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
|
import { onlineClusterPayload } from 'components/Cluster/__tests__/fixtures';
|
||||||
|
|
||||||
describe('ClusterMenu', () => {
|
describe('ClusterMenu', () => {
|
||||||
const setupComponent = (cluster: Cluster, singleMode?: boolean) => (
|
const setupComponent = (cluster: Cluster, singleMode?: boolean) => (
|
||||||
|
|
|
@ -1,29 +1,38 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Nav from 'components/Nav/Nav';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from 'lib/testHelpers';
|
||||||
import {
|
import {
|
||||||
offlineClusterPayload,
|
offlineClusterPayload,
|
||||||
onlineClusterPayload,
|
onlineClusterPayload,
|
||||||
} from 'redux/reducers/clusters/__test__/fixtures';
|
} from 'components/Cluster/__tests__/fixtures';
|
||||||
import Nav from 'components/Nav/Nav';
|
import fetchMock from 'fetch-mock';
|
||||||
import { screen } from '@testing-library/react';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { render } from 'lib/testHelpers';
|
import { Cluster } from 'generated-sources';
|
||||||
|
|
||||||
describe('Nav', () => {
|
describe('Nav', () => {
|
||||||
|
afterEach(() => fetchMock.restore());
|
||||||
|
|
||||||
|
const renderComponent = async (payload: Cluster[] = []) => {
|
||||||
|
const mock = fetchMock.get('/api/clusters', payload);
|
||||||
|
await act(() => {
|
||||||
|
render(<Nav />);
|
||||||
|
});
|
||||||
|
return waitFor(() => expect(mock.called()).toBeTruthy());
|
||||||
|
};
|
||||||
|
|
||||||
const getDashboard = () => screen.getByText('Dashboard');
|
const getDashboard = () => screen.getByText('Dashboard');
|
||||||
|
|
||||||
const getMenuItemsCount = () => screen.getAllByRole('menuitem').length;
|
const getMenuItemsCount = () => screen.getAllByRole('menuitem').length;
|
||||||
it('renders loader', () => {
|
it('renders loader', async () => {
|
||||||
render(<Nav clusters={[]} />);
|
await renderComponent();
|
||||||
|
|
||||||
expect(getMenuItemsCount()).toEqual(1);
|
expect(getMenuItemsCount()).toEqual(1);
|
||||||
expect(getDashboard()).toBeInTheDocument();
|
expect(getDashboard()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders ClusterMenu', () => {
|
it('renders ClusterMenu', async () => {
|
||||||
render(
|
await renderComponent([onlineClusterPayload, offlineClusterPayload]);
|
||||||
<Nav
|
|
||||||
clusters={[onlineClusterPayload, offlineClusterPayload]}
|
|
||||||
areClustersFulfilled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getAllByRole('menu').length).toEqual(3);
|
expect(screen.getAllByRole('menu').length).toEqual(3);
|
||||||
expect(getMenuItemsCount()).toEqual(3);
|
expect(getMenuItemsCount()).toEqual(3);
|
||||||
expect(getDashboard()).toBeInTheDocument();
|
expect(getDashboard()).toBeInTheDocument();
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
TopicMessageEventTypeEnum,
|
TopicMessageEventTypeEnum,
|
||||||
} from 'generated-sources';
|
} from 'generated-sources';
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { omitBy } from 'lodash';
|
import omitBy from 'lodash/omitBy';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
|
import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Partition, SeekType } from 'generated-sources';
|
import { Partition, SeekType } from 'generated-sources';
|
||||||
import { compact } from 'lodash';
|
import compact from 'lodash/compact';
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
||||||
|
|
||||||
export const filterOptions = (options: Option[], filter: string) => {
|
export const filterOptions = (options: Option[], filter: string) => {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from 'redux/reducers/topics/topicsSlice';
|
} from 'redux/reducers/topics/topicsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
|
||||||
import { alertAdded } from 'redux/reducers/alerts/alertsSlice';
|
import { alertAdded } from 'redux/reducers/alerts/alertsSlice';
|
||||||
import { now } from 'lodash';
|
import now from 'lodash/now';
|
||||||
import { Button } from 'components/common/Button/Button';
|
import { Button } from 'components/common/Button/Button';
|
||||||
import Editor from 'components/common/Editor/Editor';
|
import Editor from 'components/common/Editor/Editor';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { TopicMessageSchema } from 'generated-sources';
|
import { TopicMessageSchema } from 'generated-sources';
|
||||||
import Ajv, { DefinedError } from 'ajv/dist/2020';
|
import Ajv, { DefinedError } from 'ajv/dist/2020';
|
||||||
import { upperFirst } from 'lodash';
|
import upperFirst from 'lodash/upperFirst';
|
||||||
|
|
||||||
const validateBySchema = (
|
const validateBySchema = (
|
||||||
value: string,
|
value: string,
|
||||||
|
|
|
@ -1,74 +1,49 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { screen, within, act } from '@testing-library/react';
|
import { screen, within } from '@testing-library/react';
|
||||||
import App from 'components/App';
|
import App from 'components/App';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
import { clustersPayload } from 'redux/reducers/clusters/__test__/fixtures';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import fetchMock from 'fetch-mock';
|
|
||||||
|
|
||||||
const burgerButtonOptions = { name: 'burger' };
|
const burgerButtonOptions = { name: 'burger' };
|
||||||
const logoutButtonOptions = { name: 'Log out' };
|
const logoutButtonOptions = { name: 'Log out' };
|
||||||
|
|
||||||
|
jest.mock('components/Nav/Nav', () => () => <div>Navigation</div>);
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
describe('initial state', () => {
|
beforeEach(() => {
|
||||||
beforeEach(() => {
|
render(<App />, {
|
||||||
render(<App />, {
|
initialEntries: ['/'],
|
||||||
initialEntries: ['/'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('shows PageLoader until clusters are fulfilled', () => {
|
|
||||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('correctly renders header', () => {
|
|
||||||
const header = screen.getByLabelText('Page Header');
|
|
||||||
expect(header).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
within(header).getByText('UI for Apache Kafka')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(within(header).getAllByRole('separator').length).toEqual(3);
|
|
||||||
expect(
|
|
||||||
within(header).getByRole('button', burgerButtonOptions)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
within(header).getByRole('button', logoutButtonOptions)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('handle burger click correctly', () => {
|
|
||||||
const header = screen.getByLabelText('Page Header');
|
|
||||||
const burger = within(header).getByRole('button', burgerButtonOptions);
|
|
||||||
const sidebar = screen.getByLabelText('Sidebar');
|
|
||||||
const overlay = screen.getByLabelText('Overlay');
|
|
||||||
expect(sidebar).toBeInTheDocument();
|
|
||||||
expect(overlay).toBeInTheDocument();
|
|
||||||
expect(overlay).toHaveStyleRule('visibility: hidden');
|
|
||||||
expect(burger).toHaveStyleRule('display: none');
|
|
||||||
userEvent.click(burger);
|
|
||||||
expect(overlay).toHaveStyleRule('visibility: visible');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with clusters list fetched', () => {
|
it('correctly renders header', () => {
|
||||||
it('shows Cluster list', async () => {
|
const header = screen.getByLabelText('Page Header');
|
||||||
const mock = fetchMock.getOnce('/api/clusters', clustersPayload);
|
expect(header).toBeInTheDocument();
|
||||||
await act(() => {
|
expect(within(header).getByText('UI for Apache Kafka')).toBeInTheDocument();
|
||||||
render(<App />, {
|
expect(within(header).getAllByRole('separator').length).toEqual(3);
|
||||||
initialEntries: ['/'],
|
expect(
|
||||||
});
|
within(header).getByRole('button', burgerButtonOptions)
|
||||||
});
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(header).getByRole('button', logoutButtonOptions)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
expect(mock.called()).toBeTruthy();
|
it('handle burger click correctly', () => {
|
||||||
|
const burger = within(screen.getByLabelText('Page Header')).getByRole(
|
||||||
|
'button',
|
||||||
|
burgerButtonOptions
|
||||||
|
);
|
||||||
|
const overlay = screen.getByLabelText('Overlay');
|
||||||
|
expect(screen.getByLabelText('Sidebar')).toBeInTheDocument();
|
||||||
|
expect(overlay).toBeInTheDocument();
|
||||||
|
expect(overlay).toHaveStyleRule('visibility: hidden');
|
||||||
|
expect(burger).toHaveStyleRule('display: none');
|
||||||
|
userEvent.click(burger);
|
||||||
|
expect(overlay).toHaveStyleRule('visibility: visible');
|
||||||
|
});
|
||||||
|
|
||||||
const menuContainer = screen.getByLabelText('Sidebar Menu');
|
it('Renders navigation', async () => {
|
||||||
expect(menuContainer).toBeInTheDocument();
|
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||||
expect(within(menuContainer).getByText('Dashboard')).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
within(menuContainer).getByText(clustersPayload[0].name)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
within(menuContainer).getByText(clustersPayload[1].name)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PER_PAGE } from 'lib/constants';
|
import { PER_PAGE } from 'lib/constants';
|
||||||
import usePagination from 'lib/hooks/usePagination';
|
import usePagination from 'lib/hooks/usePagination';
|
||||||
import { range } from 'lodash';
|
import range from 'lodash/range';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PageControl from 'components/common/Pagination/PageControl';
|
import PageControl from 'components/common/Pagination/PageControl';
|
||||||
import useSearch from 'lib/hooks/useSearch';
|
import useSearch from 'lib/hooks/useSearch';
|
||||||
|
|
8
kafka-ui-react-app/src/lib/hooks/api/useClusters.ts
Normal file
8
kafka-ui-react-app/src/lib/hooks/api/useClusters.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { clustersApiClient } from 'lib/api';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
export default function useClusters() {
|
||||||
|
return useQuery(['clusters'], () => clustersApiClient.getClusters(), {
|
||||||
|
suspense: true,
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { isObject } from 'lodash';
|
import isObject from 'lodash/isObject';
|
||||||
import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
|
import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
|
||||||
import { useAppDispatch } from 'lib/hooks/redux';
|
import { useAppDispatch } from 'lib/hooks/redux';
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
PayloadAction,
|
PayloadAction,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers';
|
import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers';
|
||||||
import { now } from 'lodash';
|
import now from 'lodash/now';
|
||||||
import { Alert, RootState, ServerResponse } from 'redux/interfaces';
|
import { Alert, RootState, ServerResponse } from 'redux/interfaces';
|
||||||
|
|
||||||
const alertsAdapter = createEntityAdapter<Alert>({
|
const alertsAdapter = createEntityAdapter<Alert>({
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
import fetchMock from 'fetch-mock-jest';
|
|
||||||
import reducer, { fetchClusters } from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
|
|
||||||
|
|
||||||
import { clustersPayload } from './fixtures';
|
|
||||||
|
|
||||||
const store = mockStoreCreator;
|
|
||||||
|
|
||||||
describe('Clusters Slice', () => {
|
|
||||||
describe('Reducer', () => {
|
|
||||||
it('returns the initial state', () => {
|
|
||||||
expect(reducer(undefined, { type: fetchClusters.pending })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reacts on fetchClusters.fulfilled and returns payload', () => {
|
|
||||||
expect(
|
|
||||||
reducer([], {
|
|
||||||
type: fetchClusters.fulfilled,
|
|
||||||
payload: clustersPayload,
|
|
||||||
})
|
|
||||||
).toEqual(clustersPayload);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('thunks', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
fetchMock.restore();
|
|
||||||
store.clearActions();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchClusters', () => {
|
|
||||||
it('creates fetchClusters.fulfilled when fetched clusters', async () => {
|
|
||||||
fetchMock.getOnce('/api/clusters', clustersPayload);
|
|
||||||
await store.dispatch(fetchClusters());
|
|
||||||
expect(
|
|
||||||
store.getActions().map(({ type, payload }) => ({ type, payload }))
|
|
||||||
).toEqual([
|
|
||||||
{ type: fetchClusters.pending.type },
|
|
||||||
{ type: fetchClusters.fulfilled.type, payload: clustersPayload },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates fetchClusters.rejected when fetched clusters', async () => {
|
|
||||||
fetchMock.getOnce('/api/clusters', 422);
|
|
||||||
await store.dispatch(fetchClusters());
|
|
||||||
expect(
|
|
||||||
store.getActions().map(({ type, payload }) => ({ type, payload }))
|
|
||||||
).toEqual([
|
|
||||||
{ type: fetchClusters.pending.type },
|
|
||||||
{ type: fetchClusters.rejected.type },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,60 +0,0 @@
|
||||||
import { store } from 'redux/store';
|
|
||||||
import {
|
|
||||||
fetchClusters,
|
|
||||||
getAreClustersFulfilled,
|
|
||||||
getClusterList,
|
|
||||||
getOnlineClusters,
|
|
||||||
getOfflineClusters,
|
|
||||||
} from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
|
|
||||||
import {
|
|
||||||
clustersPayload,
|
|
||||||
offlineClusterPayload,
|
|
||||||
onlineClusterPayload,
|
|
||||||
} from './fixtures';
|
|
||||||
|
|
||||||
describe('Clusters selectors', () => {
|
|
||||||
describe('Initial State', () => {
|
|
||||||
it('returns fetch status', () => {
|
|
||||||
expect(getAreClustersFulfilled(store.getState())).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cluster list', () => {
|
|
||||||
expect(getClusterList(store.getState())).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns online cluster list', () => {
|
|
||||||
expect(getOnlineClusters(store.getState())).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns offline cluster list', () => {
|
|
||||||
expect(getOfflineClusters(store.getState())).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('state', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
store.dispatch(fetchClusters.fulfilled(clustersPayload, '1234'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fetch status', () => {
|
|
||||||
expect(getAreClustersFulfilled(store.getState())).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cluster list', () => {
|
|
||||||
expect(getClusterList(store.getState())).toEqual(clustersPayload);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns online cluster list', () => {
|
|
||||||
expect(getOnlineClusters(store.getState())).toEqual([
|
|
||||||
onlineClusterPayload,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns offline cluster list', () => {
|
|
||||||
expect(getOfflineClusters(store.getState())).toEqual([
|
|
||||||
offlineClusterPayload,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,61 +0,0 @@
|
||||||
import {
|
|
||||||
createAsyncThunk,
|
|
||||||
createSlice,
|
|
||||||
createSelector,
|
|
||||||
} from '@reduxjs/toolkit';
|
|
||||||
import { Cluster, ServerStatus, ClusterFeaturesEnum } from 'generated-sources';
|
|
||||||
import { clustersApiClient } from 'lib/api';
|
|
||||||
import { AsyncRequestStatus } from 'lib/constants';
|
|
||||||
import { RootState } from 'redux/interfaces';
|
|
||||||
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
|
|
||||||
|
|
||||||
export const fetchClusters = createAsyncThunk(
|
|
||||||
'clusters/fetchClusters',
|
|
||||||
async () => {
|
|
||||||
const clusters: Cluster[] = await clustersApiClient.getClusters();
|
|
||||||
return clusters;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const initialState: Cluster[] = [];
|
|
||||||
export const clustersSlice = createSlice({
|
|
||||||
name: 'clusters',
|
|
||||||
initialState,
|
|
||||||
reducers: {},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder.addCase(fetchClusters.fulfilled, (_, { payload }) => payload);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const clustersState = ({ clusters }: RootState): Cluster[] => clusters;
|
|
||||||
const getClusterListFetchingStatus = createFetchingSelector(
|
|
||||||
'clusters/fetchClusters'
|
|
||||||
);
|
|
||||||
export const getAreClustersFulfilled = createSelector(
|
|
||||||
getClusterListFetchingStatus,
|
|
||||||
(status) => status === AsyncRequestStatus.fulfilled
|
|
||||||
);
|
|
||||||
export const getClusterList = createSelector(
|
|
||||||
clustersState,
|
|
||||||
(clusters) => clusters
|
|
||||||
);
|
|
||||||
export const getOnlineClusters = createSelector(getClusterList, (clusters) =>
|
|
||||||
clusters.filter(({ status }) => status === ServerStatus.ONLINE)
|
|
||||||
);
|
|
||||||
export const getOfflineClusters = createSelector(getClusterList, (clusters) =>
|
|
||||||
clusters.filter(({ status }) => status === ServerStatus.OFFLINE)
|
|
||||||
);
|
|
||||||
export const getClustersReadonlyStatus = (clusterName: string) =>
|
|
||||||
createSelector(
|
|
||||||
getClusterList,
|
|
||||||
(clusters): boolean =>
|
|
||||||
clusters.find(({ name }) => name === clusterName)?.readOnly || false
|
|
||||||
);
|
|
||||||
export const getClustersFeatures = (clusterName: string) =>
|
|
||||||
createSelector(
|
|
||||||
getClusterList,
|
|
||||||
(clusters): ClusterFeaturesEnum[] =>
|
|
||||||
clusters.find(({ name }) => name === clusterName)?.features || []
|
|
||||||
);
|
|
||||||
|
|
||||||
export default clustersSlice.reducer;
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
ConnectorState,
|
ConnectorState,
|
||||||
FullConnectorInfo,
|
FullConnectorInfo,
|
||||||
} from 'generated-sources';
|
} from 'generated-sources';
|
||||||
import { sortBy } from 'lodash';
|
import sortBy from 'lodash/sortBy';
|
||||||
import { AsyncRequestStatus } from 'lib/constants';
|
import { AsyncRequestStatus } from 'lib/constants';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { combineReducers } from '@reduxjs/toolkit';
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
import clusters from 'redux/reducers/clusters/clustersSlice';
|
|
||||||
import loader from 'redux/reducers/loader/loaderSlice';
|
import loader from 'redux/reducers/loader/loaderSlice';
|
||||||
import alerts from 'redux/reducers/alerts/alertsSlice';
|
import alerts from 'redux/reducers/alerts/alertsSlice';
|
||||||
import schemas from 'redux/reducers/schemas/schemasSlice';
|
import schemas from 'redux/reducers/schemas/schemasSlice';
|
||||||
|
@ -14,7 +13,6 @@ export default combineReducers({
|
||||||
alerts,
|
alerts,
|
||||||
topics,
|
topics,
|
||||||
topicMessages,
|
topicMessages,
|
||||||
clusters,
|
|
||||||
consumerGroups,
|
consumerGroups,
|
||||||
schemas,
|
schemas,
|
||||||
connect,
|
connect,
|
||||||
|
|
|
@ -22,7 +22,6 @@ export default defineConfig(({ mode }) => {
|
||||||
'styled-components',
|
'styled-components',
|
||||||
'react-ace',
|
'react-ace',
|
||||||
],
|
],
|
||||||
lodash: ['lodash'],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Reference in a new issue