Kafka Connect. Initial setup (#362)
* Refactor Nav. Use feature flags. Connect * Refactor Alerts * Kafka Connect initial setup
This commit is contained in:
parent
dbadff8f2e
commit
9d75dbdacd
42 changed files with 931 additions and 190 deletions
|
@ -1,29 +1,70 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Switch, Route, Redirect, useParams } from 'react-router-dom';
|
import { Switch, Route, Redirect, useParams } from 'react-router-dom';
|
||||||
import BrokersContainer from 'components/Brokers/BrokersContainer';
|
import { ClusterFeaturesEnum } from 'generated-sources';
|
||||||
|
import {
|
||||||
|
getClustersFeatures,
|
||||||
|
getClustersReadonlyStatus,
|
||||||
|
} from 'redux/reducers/clusters/selectors';
|
||||||
|
import {
|
||||||
|
clusterBrokersPath,
|
||||||
|
clusterConnectorsPath,
|
||||||
|
clusterConsumerGroupsPath,
|
||||||
|
clusterSchemasPath,
|
||||||
|
clusterTopicsPath,
|
||||||
|
} from 'lib/paths';
|
||||||
import Topics from 'components/Topics/Topics';
|
import Topics from 'components/Topics/Topics';
|
||||||
import ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
|
|
||||||
import Schemas from 'components/Schemas/Schemas';
|
import Schemas from 'components/Schemas/Schemas';
|
||||||
import { getClustersReadonlyStatus } from 'redux/reducers/clusters/selectors';
|
import Connect from 'components/Connect/Connect';
|
||||||
import ClusterContext from 'components/contexts/ClusterContext';
|
import ClusterContext from 'components/contexts/ClusterContext';
|
||||||
|
import BrokersContainer from 'components/Brokers/BrokersContainer';
|
||||||
|
import ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
|
||||||
|
|
||||||
const Cluster: React.FC = () => {
|
const Cluster: React.FC = () => {
|
||||||
const { clusterName } = useParams<{ clusterName: string }>();
|
const { clusterName } = useParams<{ clusterName: string }>();
|
||||||
const isReadOnly = useSelector(getClustersReadonlyStatus(clusterName));
|
const isReadOnly = useSelector(getClustersReadonlyStatus(clusterName));
|
||||||
|
const features = useSelector(getClustersFeatures(clusterName));
|
||||||
|
|
||||||
|
const hasKafkaConnectConfigured = features.includes(
|
||||||
|
ClusterFeaturesEnum.KAFKA_CONNECT
|
||||||
|
);
|
||||||
|
const hasSchemaRegistryConfigured = features.includes(
|
||||||
|
ClusterFeaturesEnum.SCHEMA_REGISTRY
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
isReadOnly,
|
||||||
|
hasKafkaConnectConfigured,
|
||||||
|
hasSchemaRegistryConfigured,
|
||||||
|
}),
|
||||||
|
[features]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClusterContext.Provider value={{ isReadOnly }}>
|
<ClusterContext.Provider value={contextValue}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route
|
||||||
path="/ui/clusters/:clusterName/brokers"
|
path={clusterBrokersPath(':clusterName')}
|
||||||
component={BrokersContainer}
|
component={BrokersContainer}
|
||||||
/>
|
/>
|
||||||
<Route path="/ui/clusters/:clusterName/topics" component={Topics} />
|
<Route path={clusterTopicsPath(':clusterName')} component={Topics} />
|
||||||
<Route
|
<Route
|
||||||
path="/ui/clusters/:clusterName/consumer-groups"
|
path={clusterConsumerGroupsPath(':clusterName')}
|
||||||
component={ConsumersGroupsContainer}
|
component={ConsumersGroupsContainer}
|
||||||
/>
|
/>
|
||||||
<Route path="/ui/clusters/:clusterName/schemas" component={Schemas} />
|
{hasSchemaRegistryConfigured && (
|
||||||
|
<Route
|
||||||
|
path={clusterSchemasPath(':clusterName')}
|
||||||
|
component={Schemas}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasKafkaConnectConfigured && (
|
||||||
|
<Route
|
||||||
|
path={clusterConnectorsPath(':clusterName')}
|
||||||
|
component={Connect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Redirect
|
<Redirect
|
||||||
from="/ui/clusters/:clusterName"
|
from="/ui/clusters/:clusterName"
|
||||||
to="/ui/clusters/:clusterName/brokers"
|
to="/ui/clusters/:clusterName/brokers"
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { Route, StaticRouter } from 'react-router-dom';
|
||||||
|
import { ClusterFeaturesEnum } from 'generated-sources';
|
||||||
|
import { fetchClusterListAction } from 'redux/actions';
|
||||||
|
import configureStore from 'redux/store/configureStore';
|
||||||
|
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures';
|
||||||
|
import Cluster from '../Cluster';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
jest.mock('components/Topics/Topics', () => 'mock-Topics');
|
||||||
|
jest.mock('components/Schemas/Schemas', () => 'mock-Schemas');
|
||||||
|
jest.mock('components/Connect/Connect', () => 'mock-Connect');
|
||||||
|
jest.mock('components/Brokers/BrokersContainer', () => 'mock-Brokers');
|
||||||
|
jest.mock(
|
||||||
|
'components/ConsumerGroups/ConsumersGroupsContainer',
|
||||||
|
() => 'mock-ConsumerGroups'
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Cluster', () => {
|
||||||
|
const setupComponent = (pathname: string) => (
|
||||||
|
<Provider store={store}>
|
||||||
|
<StaticRouter location={{ pathname }}>
|
||||||
|
<Route path="/ui/clusters/:clusterName">
|
||||||
|
<Cluster />
|
||||||
|
</Route>
|
||||||
|
</StaticRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
it('renders Brokers', () => {
|
||||||
|
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/brokers'));
|
||||||
|
expect(wrapper.exists('mock-Brokers')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('renders Topics', () => {
|
||||||
|
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/topics'));
|
||||||
|
expect(wrapper.exists('mock-Topics')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('renders ConsumerGroups', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
setupComponent('/ui/clusters/secondLocal/consumer-groups')
|
||||||
|
);
|
||||||
|
expect(wrapper.exists('mock-ConsumerGroups')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configured features', () => {
|
||||||
|
it('does not render Schemas if SCHEMA_REGISTRY is not configured', () => {
|
||||||
|
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/schemas'));
|
||||||
|
expect(wrapper.exists('mock-Schemas')).toBeFalsy();
|
||||||
|
});
|
||||||
|
it('renders Schemas if SCHEMA_REGISTRY is configured', () => {
|
||||||
|
store.dispatch(
|
||||||
|
fetchClusterListAction.success([
|
||||||
|
{
|
||||||
|
...onlineClusterPayload,
|
||||||
|
features: [ClusterFeaturesEnum.SCHEMA_REGISTRY],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const wrapper = mount(setupComponent('/ui/clusters/secondLocal/schemas'));
|
||||||
|
expect(wrapper.exists('mock-Schemas')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('does not render Connect if KAFKA_CONNECT is not configured', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
setupComponent('/ui/clusters/secondLocal/connectors')
|
||||||
|
);
|
||||||
|
expect(wrapper.exists('mock-Connect')).toBeFalsy();
|
||||||
|
});
|
||||||
|
it('renders Schemas if KAFKA_CONNECT is configured', async () => {
|
||||||
|
await store.dispatch(
|
||||||
|
fetchClusterListAction.success([
|
||||||
|
{
|
||||||
|
...onlineClusterPayload,
|
||||||
|
features: [ClusterFeaturesEnum.KAFKA_CONNECT],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const wrapper = mount(
|
||||||
|
setupComponent('/ui/clusters/secondLocal/connectors')
|
||||||
|
);
|
||||||
|
expect(wrapper.exists('mock-Connect')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
16
kafka-ui-react-app/src/components/Connect/Connect.tsx
Normal file
16
kafka-ui-react-app/src/components/Connect/Connect.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Switch, Route } from 'react-router-dom';
|
||||||
|
import { clusterConnectorsPath } from 'lib/paths';
|
||||||
|
import ListContainer from './List/ListContainer';
|
||||||
|
|
||||||
|
const Connect: React.FC = () => (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={clusterConnectorsPath(':clusterName')}
|
||||||
|
component={ListContainer}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Connect;
|
87
kafka-ui-react-app/src/components/Connect/List/List.tsx
Normal file
87
kafka-ui-react-app/src/components/Connect/List/List.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Connect, Connector } from 'generated-sources';
|
||||||
|
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
|
||||||
|
import ClusterContext from 'components/contexts/ClusterContext';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { ClusterName } from 'redux/interfaces';
|
||||||
|
import Indicator from 'components/common/Dashboard/Indicator';
|
||||||
|
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
|
||||||
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
|
|
||||||
|
export interface ListProps {
|
||||||
|
areConnectsFetching: boolean;
|
||||||
|
areConnectorsFetching: boolean;
|
||||||
|
connectors: Connector[];
|
||||||
|
connects: Connect[];
|
||||||
|
fetchConnects(clusterName: ClusterName): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const List: React.FC<ListProps> = ({
|
||||||
|
connectors,
|
||||||
|
connects,
|
||||||
|
areConnectsFetching,
|
||||||
|
areConnectorsFetching,
|
||||||
|
fetchConnects,
|
||||||
|
}) => {
|
||||||
|
const { isReadOnly } = React.useContext(ClusterContext);
|
||||||
|
const { clusterName } = useParams<{ clusterName: string }>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchConnects(clusterName);
|
||||||
|
}, [fetchConnects, clusterName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<Breadcrumb>All Connectors</Breadcrumb>
|
||||||
|
<div className="box has-background-danger has-text-centered has-text-light">
|
||||||
|
Kafka Connect section is under construction.
|
||||||
|
</div>
|
||||||
|
<MetricsWrapper>
|
||||||
|
<Indicator
|
||||||
|
label="Connects"
|
||||||
|
title="Connects"
|
||||||
|
fetching={areConnectsFetching}
|
||||||
|
>
|
||||||
|
{connects.length}
|
||||||
|
</Indicator>
|
||||||
|
|
||||||
|
{!isReadOnly && (
|
||||||
|
<div className="level-item level-right">
|
||||||
|
<button type="button" className="button is-primary" disabled>
|
||||||
|
Create Connector
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MetricsWrapper>
|
||||||
|
{areConnectorsFetching ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
|
<div className="box">
|
||||||
|
<table className="table is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Connect</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Plugin</th>
|
||||||
|
<th>Topics</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Tasks</th>
|
||||||
|
<th> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{connectors.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10}>No connectors found</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default List;
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { RootState } from 'redux/interfaces';
|
||||||
|
import {
|
||||||
|
fetchConnects,
|
||||||
|
fetchConnectors,
|
||||||
|
} from 'redux/actions/thunks/connectors';
|
||||||
|
import {
|
||||||
|
getConnects,
|
||||||
|
getConnectors,
|
||||||
|
getAreConnectsFetching,
|
||||||
|
getAreConnectorsFetching,
|
||||||
|
} from 'redux/reducers/connect/selectors';
|
||||||
|
import List from './List';
|
||||||
|
|
||||||
|
const mapStateToProps = (state: RootState) => ({
|
||||||
|
areConnectsFetching: getAreConnectsFetching(state),
|
||||||
|
areConnectorsFetching: getAreConnectorsFetching(state),
|
||||||
|
|
||||||
|
connects: getConnects(state),
|
||||||
|
connectors: getConnectors(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
fetchConnects,
|
||||||
|
fetchConnectors,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(List);
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { StaticRouter } from 'react-router-dom';
|
||||||
|
import configureStore from 'redux/store/configureStore';
|
||||||
|
import ClusterContext, {
|
||||||
|
ContextProps,
|
||||||
|
initialValue,
|
||||||
|
} from 'components/contexts/ClusterContext';
|
||||||
|
|
||||||
|
import ListContainer from '../ListContainer';
|
||||||
|
import List, { ListProps } from '../List';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
describe('Connectors List', () => {
|
||||||
|
describe('Container', () => {
|
||||||
|
it('renders view with initial state of storage', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
<Provider store={store}>
|
||||||
|
<StaticRouter>
|
||||||
|
<ListContainer />
|
||||||
|
</StaticRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.exists(List)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('View', () => {
|
||||||
|
const fetchConnects = jest.fn();
|
||||||
|
const setupComponent = (
|
||||||
|
props: Partial<ListProps> = {},
|
||||||
|
contextValue: ContextProps = initialValue
|
||||||
|
) => (
|
||||||
|
<StaticRouter>
|
||||||
|
<ClusterContext.Provider value={contextValue}>
|
||||||
|
<List
|
||||||
|
areConnectorsFetching
|
||||||
|
areConnectsFetching
|
||||||
|
connectors={[]}
|
||||||
|
connects={[]}
|
||||||
|
fetchConnects={fetchConnects}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ClusterContext.Provider>
|
||||||
|
</StaticRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
it('renders PageLoader', () => {
|
||||||
|
const wrapper = mount(setupComponent({ areConnectorsFetching: true }));
|
||||||
|
expect(wrapper.exists('PageLoader')).toBeTruthy();
|
||||||
|
expect(wrapper.exists('table')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders table', () => {
|
||||||
|
const wrapper = mount(setupComponent({ areConnectorsFetching: false }));
|
||||||
|
expect(wrapper.exists('PageLoader')).toBeFalsy();
|
||||||
|
expect(wrapper.exists('table')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fetchConnects', () => {
|
||||||
|
mount(setupComponent());
|
||||||
|
expect(fetchConnects).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions if cluster is not readonly', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
setupComponent({}, { ...initialValue, isReadOnly: false })
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper.exists('.level-item.level-right > button.is-primary')
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readonly cluster', () => {
|
||||||
|
it('does not render actions if cluster is readonly', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
setupComponent({}, { ...initialValue, isReadOnly: true })
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper.exists('.level-item.level-right > button.is-primary')
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,101 +1,95 @@
|
||||||
import React, { CSSProperties } from 'react';
|
import React from 'react';
|
||||||
|
import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
clusterBrokersPath,
|
clusterBrokersPath,
|
||||||
clusterTopicsPath,
|
clusterTopicsPath,
|
||||||
clusterConsumerGroupsPath,
|
clusterConsumerGroupsPath,
|
||||||
clusterSchemasPath,
|
clusterSchemasPath,
|
||||||
|
clusterConnectorsPath,
|
||||||
} from 'lib/paths';
|
} from 'lib/paths';
|
||||||
import { Cluster, ServerStatus } from 'generated-sources';
|
import DefaultClusterIcon from './DefaultClusterIcon';
|
||||||
|
import ClusterStatusIcon from './ClusterStatusIcon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultIcon: React.FC = () => {
|
const ClusterMenu: React.FC<Props> = ({
|
||||||
const style: CSSProperties = {
|
cluster: { name, status, defaultCluster, features },
|
||||||
width: '.6rem',
|
}) => {
|
||||||
left: '-8px',
|
const hasFeatureConfigured = React.useCallback(
|
||||||
top: '-4px',
|
(key) => features?.includes(key),
|
||||||
position: 'relative',
|
[features]
|
||||||
};
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span title="Default Cluster" className="icon has-text-primary is-small">
|
<ul className="menu-list">
|
||||||
<i
|
<li>
|
||||||
style={style}
|
<NavLink
|
||||||
data-fa-transform="rotate-340"
|
exact
|
||||||
className="fas fa-thumbtack"
|
to={clusterBrokersPath(name)}
|
||||||
/>
|
title={name}
|
||||||
</span>
|
className="has-text-overflow-ellipsis"
|
||||||
|
>
|
||||||
|
{defaultCluster && <DefaultClusterIcon />}
|
||||||
|
{name}
|
||||||
|
<ClusterStatusIcon status={status} />
|
||||||
|
</NavLink>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={clusterBrokersPath(name)}
|
||||||
|
activeClassName="is-active"
|
||||||
|
title="Brokers"
|
||||||
|
>
|
||||||
|
Brokers
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={clusterTopicsPath(name)}
|
||||||
|
activeClassName="is-active"
|
||||||
|
title="Topics"
|
||||||
|
>
|
||||||
|
Topics
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={clusterConsumerGroupsPath(name)}
|
||||||
|
activeClassName="is-active"
|
||||||
|
title="Consumers"
|
||||||
|
>
|
||||||
|
Consumers
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{hasFeatureConfigured(ClusterFeaturesEnum.SCHEMA_REGISTRY) && (
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={clusterSchemasPath(name)}
|
||||||
|
activeClassName="is-active"
|
||||||
|
title="Schema Registry"
|
||||||
|
>
|
||||||
|
Schema Registry
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_CONNECT) && (
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={clusterConnectorsPath(name)}
|
||||||
|
activeClassName="is-active"
|
||||||
|
title="Kafka Connect"
|
||||||
|
>
|
||||||
|
Kafka Connect
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusIcon: React.FC<Props> = ({ cluster }) => {
|
|
||||||
const style: CSSProperties = {
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
marginLeft: '7px',
|
|
||||||
padding: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`tag ${
|
|
||||||
cluster.status === ServerStatus.ONLINE ? 'is-primary' : 'is-danger'
|
|
||||||
}`}
|
|
||||||
title={cluster.status}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClusterMenu: React.FC<Props> = ({ cluster }) => (
|
|
||||||
<ul className="menu-list">
|
|
||||||
<li>
|
|
||||||
<NavLink
|
|
||||||
exact
|
|
||||||
to={clusterBrokersPath(cluster.name)}
|
|
||||||
title={cluster.name}
|
|
||||||
className="has-text-overflow-ellipsis"
|
|
||||||
>
|
|
||||||
{cluster.defaultCluster && <DefaultIcon />}
|
|
||||||
{cluster.name}
|
|
||||||
<StatusIcon cluster={cluster} />
|
|
||||||
</NavLink>
|
|
||||||
<ul>
|
|
||||||
<NavLink
|
|
||||||
to={clusterBrokersPath(cluster.name)}
|
|
||||||
activeClassName="is-active"
|
|
||||||
title="Brokers"
|
|
||||||
>
|
|
||||||
Brokers
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to={clusterTopicsPath(cluster.name)}
|
|
||||||
activeClassName="is-active"
|
|
||||||
title="Topics"
|
|
||||||
>
|
|
||||||
Topics
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to={clusterConsumerGroupsPath(cluster.name)}
|
|
||||||
activeClassName="is-active"
|
|
||||||
title="Consumers"
|
|
||||||
>
|
|
||||||
Consumers
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to={clusterSchemasPath(cluster.name)}
|
|
||||||
activeClassName="is-active"
|
|
||||||
title="Schema Registry"
|
|
||||||
>
|
|
||||||
Schema Registry
|
|
||||||
</NavLink>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ClusterMenu;
|
export default ClusterMenu;
|
||||||
|
|
28
kafka-ui-react-app/src/components/Nav/ClusterStatusIcon.tsx
Normal file
28
kafka-ui-react-app/src/components/Nav/ClusterStatusIcon.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { ServerStatus } from 'generated-sources';
|
||||||
|
import React, { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: ServerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClusterStatusIcon: React.FC<Props> = ({ status }) => {
|
||||||
|
const style: CSSProperties = {
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
marginLeft: '7px',
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`tag ${
|
||||||
|
status === ServerStatus.ONLINE ? 'is-primary' : 'is-danger'
|
||||||
|
}`}
|
||||||
|
title={status}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClusterStatusIcon;
|
22
kafka-ui-react-app/src/components/Nav/DefaultClusterIcon.tsx
Normal file
22
kafka-ui-react-app/src/components/Nav/DefaultClusterIcon.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
const DefaultClusterIcon: React.FC = () => {
|
||||||
|
const style: CSSProperties = {
|
||||||
|
width: '.6rem',
|
||||||
|
left: '-8px',
|
||||||
|
top: '-4px',
|
||||||
|
position: 'relative',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span title="Default Cluster" className="icon has-text-primary is-small">
|
||||||
|
<i
|
||||||
|
style={style}
|
||||||
|
data-fa-transform="rotate-340"
|
||||||
|
className="fas fa-thumbtack"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DefaultClusterIcon;
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { StaticRouter } from 'react-router';
|
||||||
|
import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
|
||||||
|
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures';
|
||||||
|
import ClusterMenu from '../ClusterMenu';
|
||||||
|
|
||||||
|
describe('ClusterMenu', () => {
|
||||||
|
const setupComponent = (cluster: Cluster) => (
|
||||||
|
<StaticRouter>
|
||||||
|
<ClusterMenu cluster={cluster} />
|
||||||
|
</StaticRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
it('renders cluster menu without Kafka Connect & Schema Registry', () => {
|
||||||
|
const wrapper = mount(setupComponent(onlineClusterPayload));
|
||||||
|
expect(wrapper.find('ul.menu-list > li > NavLink').text()).toEqual(
|
||||||
|
onlineClusterPayload.name
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('ul.menu-list ul > li').length).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cluster menu with all enabled features', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
setupComponent({
|
||||||
|
...onlineClusterPayload,
|
||||||
|
features: [
|
||||||
|
ClusterFeaturesEnum.KAFKA_CONNECT,
|
||||||
|
ClusterFeaturesEnum.SCHEMA_REGISTRY,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ul.menu-list ul > li').length).toEqual(5);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { ServerStatus } from 'generated-sources';
|
||||||
|
import ClusterStatusIcon from '../ClusterStatusIcon';
|
||||||
|
|
||||||
|
describe('ClusterStatusIcon', () => {
|
||||||
|
it('matches snapshot', () => {
|
||||||
|
const wrapper = mount(<ClusterStatusIcon status={ServerStatus.ONLINE} />);
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders online icon', () => {
|
||||||
|
const wrapper = mount(<ClusterStatusIcon status={ServerStatus.ONLINE} />);
|
||||||
|
expect(wrapper.exists('.is-primary')).toBeTruthy();
|
||||||
|
expect(wrapper.exists('.is-danger')).toBeFalsy();
|
||||||
|
});
|
||||||
|
it('renders offline icon', () => {
|
||||||
|
const wrapper = mount(<ClusterStatusIcon status={ServerStatus.OFFLINE} />);
|
||||||
|
expect(wrapper.exists('.is-danger')).toBeTruthy();
|
||||||
|
expect(wrapper.exists('.is-primary')).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ClusterStatusIcon matches snapshot 1`] = `
|
||||||
|
<ClusterStatusIcon
|
||||||
|
status="online"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="tag is-primary"
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"borderRadius": "5px",
|
||||||
|
"height": "10px",
|
||||||
|
"marginLeft": "7px",
|
||||||
|
"padding": 0,
|
||||||
|
"width": "10px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title="online"
|
||||||
|
/>
|
||||||
|
</ClusterStatusIcon>
|
||||||
|
`;
|
|
@ -110,7 +110,13 @@ describe('Details', () => {
|
||||||
expect(
|
expect(
|
||||||
mount(
|
mount(
|
||||||
<StaticRouter>
|
<StaticRouter>
|
||||||
<ClusterContext.Provider value={{ isReadOnly: true }}>
|
<ClusterContext.Provider
|
||||||
|
value={{
|
||||||
|
isReadOnly: true,
|
||||||
|
hasKafkaConnectConfigured: true,
|
||||||
|
hasSchemaRegistryConfigured: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{setupWrapper({ versions })}
|
{setupWrapper({ versions })}
|
||||||
</ClusterContext.Provider>
|
</ClusterContext.Provider>
|
||||||
</StaticRouter>
|
</StaticRouter>
|
||||||
|
|
|
@ -91,7 +91,13 @@ describe('List', () => {
|
||||||
describe('with readonly cluster', () => {
|
describe('with readonly cluster', () => {
|
||||||
const wrapper = mount(
|
const wrapper = mount(
|
||||||
<StaticRouter>
|
<StaticRouter>
|
||||||
<ClusterContext.Provider value={{ isReadOnly: true }}>
|
<ClusterContext.Provider
|
||||||
|
value={{
|
||||||
|
isReadOnly: true,
|
||||||
|
hasKafkaConnectConfigured: true,
|
||||||
|
hasSchemaRegistryConfigured: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{setupWrapper({ schemas: [] })}
|
{setupWrapper({ schemas: [] })}
|
||||||
</ClusterContext.Provider>
|
</ClusterContext.Provider>
|
||||||
</StaticRouter>
|
</StaticRouter>
|
||||||
|
|
|
@ -9,7 +9,13 @@ describe('List', () => {
|
||||||
it('does not render the Add a Topic button', () => {
|
it('does not render the Add a Topic button', () => {
|
||||||
const component = mount(
|
const component = mount(
|
||||||
<StaticRouter>
|
<StaticRouter>
|
||||||
<ClusterContext.Provider value={{ isReadOnly: true }}>
|
<ClusterContext.Provider
|
||||||
|
value={{
|
||||||
|
isReadOnly: true,
|
||||||
|
hasKafkaConnectConfigured: true,
|
||||||
|
hasSchemaRegistryConfigured: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<List
|
<List
|
||||||
areTopicsFetching={false}
|
areTopicsFetching={false}
|
||||||
topics={[]}
|
topics={[]}
|
||||||
|
@ -29,7 +35,13 @@ describe('List', () => {
|
||||||
it('renders the Add a Topic button', () => {
|
it('renders the Add a Topic button', () => {
|
||||||
const component = mount(
|
const component = mount(
|
||||||
<StaticRouter>
|
<StaticRouter>
|
||||||
<ClusterContext.Provider value={{ isReadOnly: false }}>
|
<ClusterContext.Provider
|
||||||
|
value={{
|
||||||
|
isReadOnly: false,
|
||||||
|
hasKafkaConnectConfigured: true,
|
||||||
|
hasSchemaRegistryConfigured: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<List
|
<List
|
||||||
areTopicsFetching={false}
|
areTopicsFetching={false}
|
||||||
topics={[]}
|
topics={[]}
|
||||||
|
|
|
@ -2,17 +2,32 @@ import React from 'react';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
fetching?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Indicator: React.FC<Props> = ({ label, title, className, children }) => {
|
const Indicator: React.FC<Props> = ({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
fetching,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={cx('level-item', 'level-left', className)}>
|
<div className={cx('level-item', 'level-left', className)}>
|
||||||
<div title={title || label}>
|
<div title={title || label}>
|
||||||
<p className="heading">{label}</p>
|
<p className="heading">{label}</p>
|
||||||
<p className="title">{children}</p>
|
<p className="title has-text-centered">
|
||||||
|
{fetching ? (
|
||||||
|
<span className="icon has-text-grey-light">
|
||||||
|
<i className="fas fa-spinner fa-pulse" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,7 +17,7 @@ exports[`Indicator matches the snapshot 1`] = `
|
||||||
label
|
label
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="title"
|
className="title has-text-centered"
|
||||||
>
|
>
|
||||||
Child
|
Child
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const initialValue: { isReadOnly: boolean } = {
|
export interface ContextProps {
|
||||||
|
isReadOnly: boolean;
|
||||||
|
hasKafkaConnectConfigured: boolean;
|
||||||
|
hasSchemaRegistryConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialValue: ContextProps = {
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
|
hasKafkaConnectConfigured: false,
|
||||||
|
hasSchemaRegistryConfigured: false,
|
||||||
};
|
};
|
||||||
const ClusterContext = React.createContext(initialValue);
|
const ClusterContext = React.createContext(initialValue);
|
||||||
|
|
||||||
|
|
|
@ -66,4 +66,10 @@ describe('Paths', () => {
|
||||||
'/ui/clusters/local/topics/topic123/edit'
|
'/ui/clusters/local/topics/topic123/edit'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clusterConnectorsPath', () => {
|
||||||
|
expect(paths.clusterConnectorsPath('local')).toEqual(
|
||||||
|
'/ui/clusters/local/connectors'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -45,3 +45,7 @@ export const clusterTopicsTopicEditPath = (
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
topicName: TopicName
|
topicName: TopicName
|
||||||
) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;
|
) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;
|
||||||
|
|
||||||
|
// Kafka Connect
|
||||||
|
export const clusterConnectorsPath = (clusterName: ClusterName) =>
|
||||||
|
`${clusterPath(clusterName)}/connectors`;
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
import fetchMock from 'fetch-mock-jest';
|
||||||
|
import * as actions from 'redux/actions/actions';
|
||||||
|
import * as thunks from 'redux/actions/thunks';
|
||||||
|
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
|
||||||
|
|
||||||
|
const store = mockStoreCreator;
|
||||||
|
const clusterName = 'local';
|
||||||
|
|
||||||
|
describe('Thunks', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.restore();
|
||||||
|
store.clearActions();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchConnects', () => {
|
||||||
|
it('creates GET_CONNECTS__SUCCESS when fetching connects', async () => {
|
||||||
|
fetchMock.getOnce(`/api/clusters/${clusterName}/connects`, [
|
||||||
|
{ name: 'first', address: 'localhost' },
|
||||||
|
]);
|
||||||
|
await store.dispatch(thunks.fetchConnects(clusterName));
|
||||||
|
expect(store.getActions()).toEqual([
|
||||||
|
actions.fetchConnectsAction.request(),
|
||||||
|
actions.fetchConnectsAction.success({
|
||||||
|
...store.getState().connect,
|
||||||
|
connects: [{ name: 'first', address: 'localhost' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates GET_CONNECTS__FAILURE', async () => {
|
||||||
|
fetchMock.getOnce(`/api/clusters/${clusterName}/connects`, 404);
|
||||||
|
await store.dispatch(thunks.fetchConnects(clusterName));
|
||||||
|
expect(store.getActions()).toEqual([
|
||||||
|
actions.fetchConnectsAction.request(),
|
||||||
|
actions.fetchConnectsAction.failure({
|
||||||
|
alert: {
|
||||||
|
subject: 'connects',
|
||||||
|
title: `Kafka Connect`,
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
body: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,25 +1,11 @@
|
||||||
import configureMockStore, {
|
|
||||||
MockStoreCreator,
|
|
||||||
MockStoreEnhanced,
|
|
||||||
} from 'redux-mock-store';
|
|
||||||
import thunk, { ThunkDispatch } from 'redux-thunk';
|
|
||||||
import fetchMock from 'fetch-mock-jest';
|
import fetchMock from 'fetch-mock-jest';
|
||||||
import { Middleware } from 'redux';
|
|
||||||
import { RootState, Action } from 'redux/interfaces';
|
|
||||||
import * as actions from 'redux/actions/actions';
|
import * as actions from 'redux/actions/actions';
|
||||||
import * as thunks from 'redux/actions/thunks';
|
import * as thunks from 'redux/actions/thunks';
|
||||||
import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
|
import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
|
||||||
|
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
|
||||||
import * as fixtures from '../fixtures';
|
import * as fixtures from '../fixtures';
|
||||||
|
|
||||||
const middlewares: Array<Middleware> = [thunk];
|
const store = mockStoreCreator;
|
||||||
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
|
|
||||||
|
|
||||||
const mockStoreCreator: MockStoreCreator<
|
|
||||||
RootState,
|
|
||||||
DispatchExts
|
|
||||||
> = configureMockStore<RootState, DispatchExts>(middlewares);
|
|
||||||
|
|
||||||
const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
|
|
||||||
|
|
||||||
const clusterName = 'local';
|
const clusterName = 'local';
|
||||||
const subject = 'test';
|
const subject = 'test';
|
||||||
|
|
|
@ -1,23 +1,9 @@
|
||||||
import configureMockStore, {
|
|
||||||
MockStoreCreator,
|
|
||||||
MockStoreEnhanced,
|
|
||||||
} from 'redux-mock-store';
|
|
||||||
import thunk, { ThunkDispatch } from 'redux-thunk';
|
|
||||||
import fetchMock from 'fetch-mock-jest';
|
import fetchMock from 'fetch-mock-jest';
|
||||||
import { Middleware } from 'redux';
|
|
||||||
import { RootState, Action } from 'redux/interfaces';
|
|
||||||
import * as actions from 'redux/actions/actions';
|
import * as actions from 'redux/actions/actions';
|
||||||
import * as thunks from 'redux/actions/thunks';
|
import * as thunks from 'redux/actions/thunks';
|
||||||
|
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
|
||||||
|
|
||||||
const middlewares: Array<Middleware> = [thunk];
|
const store = mockStoreCreator;
|
||||||
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
|
|
||||||
|
|
||||||
const mockStoreCreator: MockStoreCreator<
|
|
||||||
RootState,
|
|
||||||
DispatchExts
|
|
||||||
> = configureMockStore<RootState, DispatchExts>(middlewares);
|
|
||||||
|
|
||||||
const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
|
|
||||||
|
|
||||||
const clusterName = 'local';
|
const clusterName = 'local';
|
||||||
const topicName = 'localTopic';
|
const topicName = 'localTopic';
|
||||||
|
|
|
@ -4,8 +4,8 @@ import {
|
||||||
FailurePayload,
|
FailurePayload,
|
||||||
TopicName,
|
TopicName,
|
||||||
TopicsState,
|
TopicsState,
|
||||||
|
ConnectState,
|
||||||
} from 'redux/interfaces';
|
} from 'redux/interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Cluster,
|
Cluster,
|
||||||
ClusterStats,
|
ClusterStats,
|
||||||
|
@ -125,3 +125,21 @@ export const createSchemaAction = createAsyncAction(
|
||||||
)<undefined, SchemaSubject, { alert?: FailurePayload }>();
|
)<undefined, SchemaSubject, { alert?: FailurePayload }>();
|
||||||
|
|
||||||
export const dismissAlert = createAction('DISMISS_ALERT')<string>();
|
export const dismissAlert = createAction('DISMISS_ALERT')<string>();
|
||||||
|
|
||||||
|
export const fetchConnectsAction = createAsyncAction(
|
||||||
|
'GET_CONNECTS__REQUEST',
|
||||||
|
'GET_CONNECTS__SUCCESS',
|
||||||
|
'GET_CONNECTS__FAILURE'
|
||||||
|
)<undefined, ConnectState, { alert?: FailurePayload }>();
|
||||||
|
|
||||||
|
export const fetchConnectorsAction = createAsyncAction(
|
||||||
|
'GET_CONNECTORS__REQUEST',
|
||||||
|
'GET_CONNECTORS__SUCCESS',
|
||||||
|
'GET_CONNECTORS__FAILURE'
|
||||||
|
)<undefined, ConnectState, { alert?: FailurePayload }>();
|
||||||
|
|
||||||
|
export const fetchConnectorAction = createAsyncAction(
|
||||||
|
'GET_CONNECTOR__REQUEST',
|
||||||
|
'GET_CONNECTOR__SUCCESS',
|
||||||
|
'GET_CONNECTOR__FAILURE'
|
||||||
|
)<undefined, ConnectState, { alert?: FailurePayload }>();
|
||||||
|
|
94
kafka-ui-react-app/src/redux/actions/thunks/connectors.ts
Normal file
94
kafka-ui-react-app/src/redux/actions/thunks/connectors.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { KafkaConnectApi, Configuration } from 'generated-sources';
|
||||||
|
import { BASE_PARAMS } from 'lib/constants';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClusterName,
|
||||||
|
FailurePayload,
|
||||||
|
PromiseThunkResult,
|
||||||
|
} from 'redux/interfaces';
|
||||||
|
import * as actions from 'redux/actions';
|
||||||
|
import { getResponse } from 'lib/errorHandling';
|
||||||
|
|
||||||
|
const apiClientConf = new Configuration(BASE_PARAMS);
|
||||||
|
export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
|
||||||
|
|
||||||
|
export const fetchConnects = (
|
||||||
|
clusterName: ClusterName
|
||||||
|
): PromiseThunkResult<void> => async (dispatch, getState) => {
|
||||||
|
dispatch(actions.fetchConnectsAction.request());
|
||||||
|
try {
|
||||||
|
const connects = await kafkaConnectApiClient.getConnects({ clusterName });
|
||||||
|
const state = getState().connect;
|
||||||
|
dispatch(actions.fetchConnectsAction.success({ ...state, connects }));
|
||||||
|
} catch (error) {
|
||||||
|
const response = await getResponse(error);
|
||||||
|
const alert: FailurePayload = {
|
||||||
|
subject: 'connects',
|
||||||
|
title: `Kafka Connect`,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
dispatch(actions.fetchConnectsAction.failure({ alert }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchConnectors = (
|
||||||
|
clusterName: ClusterName,
|
||||||
|
connectName: string
|
||||||
|
): PromiseThunkResult<void> => async (dispatch, getState) => {
|
||||||
|
dispatch(actions.fetchConnectorsAction.request());
|
||||||
|
try {
|
||||||
|
const connectorNames = await kafkaConnectApiClient.getConnectors({
|
||||||
|
clusterName,
|
||||||
|
connectName,
|
||||||
|
});
|
||||||
|
const connectors = await Promise.all(
|
||||||
|
connectorNames.map((connectorName) =>
|
||||||
|
kafkaConnectApiClient.getConnector({
|
||||||
|
clusterName,
|
||||||
|
connectName,
|
||||||
|
connectorName,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const state = getState().connect;
|
||||||
|
dispatch(actions.fetchConnectorsAction.success({ ...state, connectors }));
|
||||||
|
} catch (error) {
|
||||||
|
const response = await getResponse(error);
|
||||||
|
const alert: FailurePayload = {
|
||||||
|
subject: ['connect', connectName, 'connectors'].join('-'),
|
||||||
|
title: `Kafka Connect ${connectName}. Connectors`,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
dispatch(actions.fetchConnectorsAction.failure({ alert }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchConnector = (
|
||||||
|
clusterName: ClusterName,
|
||||||
|
connectName: string,
|
||||||
|
connectorName: string
|
||||||
|
): PromiseThunkResult<void> => async (dispatch, getState) => {
|
||||||
|
dispatch(actions.fetchConnectorAction.request());
|
||||||
|
try {
|
||||||
|
const connector = await kafkaConnectApiClient.getConnector({
|
||||||
|
clusterName,
|
||||||
|
connectName,
|
||||||
|
connectorName,
|
||||||
|
});
|
||||||
|
const state = getState().connect;
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
connectors: [...state.connectors, connector],
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(actions.fetchConnectorAction.success(newState));
|
||||||
|
} catch (error) {
|
||||||
|
const response = await getResponse(error);
|
||||||
|
const alert: FailurePayload = {
|
||||||
|
subject: ['connect', connectName, 'connectors', connectorName].join('-'),
|
||||||
|
title: `Kafka Connect ${connectName}. Connector ${connectorName}`,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
dispatch(actions.fetchConnectorAction.failure({ alert }));
|
||||||
|
}
|
||||||
|
};
|
|
@ -3,3 +3,4 @@ export * from './clusters';
|
||||||
export * from './consumerGroups';
|
export * from './consumerGroups';
|
||||||
export * from './schemas';
|
export * from './schemas';
|
||||||
export * from './topics';
|
export * from './topics';
|
||||||
|
export * from './connectors';
|
||||||
|
|
|
@ -61,8 +61,7 @@ export const createSchema = (
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const response = await getResponse(error);
|
const response = await getResponse(error);
|
||||||
const alert: FailurePayload = {
|
const alert: FailurePayload = {
|
||||||
subject: 'schema',
|
subject: ['schema', newSchemaSubject.subject].join('-'),
|
||||||
subjectId: newSchemaSubject.subject,
|
|
||||||
title: `Schema ${newSchemaSubject.subject}`,
|
title: `Schema ${newSchemaSubject.subject}`,
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
|
@ -232,8 +232,7 @@ export const createTopic = (
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const response = await getResponse(error);
|
const response = await getResponse(error);
|
||||||
const alert: FailurePayload = {
|
const alert: FailurePayload = {
|
||||||
subjectId: form.name,
|
subject: ['schema', form.name].join('-'),
|
||||||
subject: 'schema',
|
|
||||||
title: `Schema ${form.name}`,
|
title: `Schema ${form.name}`,
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,14 +4,13 @@ import React from 'react';
|
||||||
export interface ServerResponse {
|
export interface ServerResponse {
|
||||||
status: number;
|
status: number;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
body: ErrorResponse;
|
body?: ErrorResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FailurePayload {
|
export interface FailurePayload {
|
||||||
title: string;
|
title: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
subjectId?: string | number;
|
|
||||||
response?: ServerResponse;
|
response?: ServerResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
6
kafka-ui-react-app/src/redux/interfaces/connect.ts
Normal file
6
kafka-ui-react-app/src/redux/interfaces/connect.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { Connect, Connector } from 'generated-sources';
|
||||||
|
|
||||||
|
export interface ConnectState {
|
||||||
|
connects: Connect[];
|
||||||
|
connectors: Connector[];
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { LoaderState } from './loader';
|
||||||
import { ConsumerGroupsState } from './consumerGroup';
|
import { ConsumerGroupsState } from './consumerGroup';
|
||||||
import { SchemasState } from './schema';
|
import { SchemasState } from './schema';
|
||||||
import { AlertsState } from './alerts';
|
import { AlertsState } from './alerts';
|
||||||
|
import { ConnectState } from './connect';
|
||||||
|
|
||||||
export * from './topic';
|
export * from './topic';
|
||||||
export * from './cluster';
|
export * from './cluster';
|
||||||
|
@ -16,6 +17,7 @@ export * from './consumerGroup';
|
||||||
export * from './schema';
|
export * from './schema';
|
||||||
export * from './loader';
|
export * from './loader';
|
||||||
export * from './alerts';
|
export * from './alerts';
|
||||||
|
export * from './connect';
|
||||||
|
|
||||||
export interface RootState {
|
export interface RootState {
|
||||||
topics: TopicsState;
|
topics: TopicsState;
|
||||||
|
@ -23,6 +25,7 @@ export interface RootState {
|
||||||
brokers: BrokersState;
|
brokers: BrokersState;
|
||||||
consumerGroups: ConsumerGroupsState;
|
consumerGroups: ConsumerGroupsState;
|
||||||
schemas: SchemasState;
|
schemas: SchemasState;
|
||||||
|
connect: ConnectState;
|
||||||
loader: LoaderState;
|
loader: LoaderState;
|
||||||
alerts: AlertsState;
|
alerts: AlertsState;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
export const failurePayloadWithoutId = {
|
export const failurePayload1 = {
|
||||||
title: 'title',
|
title: 'title',
|
||||||
message: 'message',
|
message: 'message',
|
||||||
subject: 'topic',
|
subject: 'topic-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const failurePayloadWithId = {
|
export const failurePayload2 = {
|
||||||
...failurePayloadWithoutId,
|
...failurePayload1,
|
||||||
subjectId: '12345',
|
subject: 'topic-2',
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { dismissAlert, createTopicAction } from 'redux/actions';
|
import { dismissAlert, createTopicAction } from 'redux/actions';
|
||||||
import reducer from 'redux/reducers/alerts/reducer';
|
import reducer from 'redux/reducers/alerts/reducer';
|
||||||
import { failurePayloadWithId, failurePayloadWithoutId } from './fixtures';
|
import { failurePayload1, failurePayload2 } from './fixtures';
|
||||||
|
|
||||||
jest.mock('lodash', () => ({
|
jest.mock('lodash', () => ({
|
||||||
...jest.requireActual('lodash'),
|
...jest.requireActual('lodash'),
|
||||||
|
@ -12,38 +12,18 @@ describe('Clusters reducer', () => {
|
||||||
expect(reducer(undefined, createTopicAction.failure({}))).toEqual({});
|
expect(reducer(undefined, createTopicAction.failure({}))).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates error alert with subjectId', () => {
|
it('creates error alert', () => {
|
||||||
expect(
|
expect(
|
||||||
reducer(
|
reducer(
|
||||||
undefined,
|
undefined,
|
||||||
createTopicAction.failure({
|
createTopicAction.failure({
|
||||||
alert: failurePayloadWithId,
|
alert: failurePayload2,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
'alert-topic12345': {
|
'alert-topic-2': {
|
||||||
createdAt: 1234567890,
|
createdAt: 1234567890,
|
||||||
id: 'alert-topic12345',
|
id: 'alert-topic-2',
|
||||||
message: 'message',
|
|
||||||
response: undefined,
|
|
||||||
title: 'title',
|
|
||||||
type: 'error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates error alert without subjectId', () => {
|
|
||||||
expect(
|
|
||||||
reducer(
|
|
||||||
undefined,
|
|
||||||
createTopicAction.failure({
|
|
||||||
alert: failurePayloadWithoutId,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).toEqual({
|
|
||||||
'alert-topic': {
|
|
||||||
createdAt: 1234567890,
|
|
||||||
id: 'alert-topic',
|
|
||||||
message: 'message',
|
message: 'message',
|
||||||
response: undefined,
|
response: undefined,
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
@ -56,23 +36,23 @@ describe('Clusters reducer', () => {
|
||||||
const state = reducer(
|
const state = reducer(
|
||||||
undefined,
|
undefined,
|
||||||
createTopicAction.failure({
|
createTopicAction.failure({
|
||||||
alert: failurePayloadWithoutId,
|
alert: failurePayload1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(reducer(state, dismissAlert('alert-topic'))).toEqual({});
|
expect(reducer(state, dismissAlert('alert-topic-1'))).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove alert if id is wrong', () => {
|
it('does not remove alert if id is wrong', () => {
|
||||||
const state = reducer(
|
const state = reducer(
|
||||||
undefined,
|
undefined,
|
||||||
createTopicAction.failure({
|
createTopicAction.failure({
|
||||||
alert: failurePayloadWithoutId,
|
alert: failurePayload1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(reducer(state, dismissAlert('wrong-id'))).toEqual({
|
expect(reducer(state, dismissAlert('wrong-id'))).toEqual({
|
||||||
'alert-topic': {
|
'alert-topic-1': {
|
||||||
createdAt: 1234567890,
|
createdAt: 1234567890,
|
||||||
id: 'alert-topic',
|
id: 'alert-topic-1',
|
||||||
message: 'message',
|
message: 'message',
|
||||||
response: undefined,
|
response: undefined,
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import configureStore from 'redux/store/configureStore';
|
import configureStore from 'redux/store/configureStore';
|
||||||
import { createTopicAction } from 'redux/actions';
|
import { createTopicAction } from 'redux/actions';
|
||||||
import * as selectors from '../selectors';
|
import * as selectors from '../selectors';
|
||||||
import { failurePayloadWithId, failurePayloadWithoutId } from './fixtures';
|
import { failurePayload1, failurePayload2 } from './fixtures';
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
|
@ -14,12 +14,8 @@ describe('Alerts selectors', () => {
|
||||||
|
|
||||||
describe('state', () => {
|
describe('state', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
store.dispatch(
|
store.dispatch(createTopicAction.failure({ alert: failurePayload1 }));
|
||||||
createTopicAction.failure({ alert: failurePayloadWithoutId })
|
store.dispatch(createTopicAction.failure({ alert: failurePayload2 }));
|
||||||
);
|
|
||||||
store.dispatch(
|
|
||||||
createTopicAction.failure({ alert: failurePayloadWithId })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns fetch status', () => {
|
it('returns fetch status', () => {
|
||||||
|
|
|
@ -8,15 +8,9 @@ export const addError = (state: AlertsState, action: Action) => {
|
||||||
'alert' in action.payload &&
|
'alert' in action.payload &&
|
||||||
action.payload.alert !== undefined
|
action.payload.alert !== undefined
|
||||||
) {
|
) {
|
||||||
const {
|
const { subject, title, message, response } = action.payload.alert;
|
||||||
subject,
|
|
||||||
subjectId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
response,
|
|
||||||
} = action.payload.alert;
|
|
||||||
|
|
||||||
const id = `alert-${subject}${subjectId || ''}`;
|
const id = `alert-${subject}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -9,6 +9,7 @@ export const onlineClusterPayload: Cluster = {
|
||||||
topicCount: 3,
|
topicCount: 3,
|
||||||
bytesInPerSec: 1.55,
|
bytesInPerSec: 1.55,
|
||||||
bytesOutPerSec: 9.314,
|
bytesOutPerSec: 9.314,
|
||||||
|
features: [],
|
||||||
};
|
};
|
||||||
export const offlineClusterPayload: Cluster = {
|
export const offlineClusterPayload: Cluster = {
|
||||||
name: 'local',
|
name: 'local',
|
||||||
|
@ -19,6 +20,7 @@ export const offlineClusterPayload: Cluster = {
|
||||||
topicCount: 2,
|
topicCount: 2,
|
||||||
bytesInPerSec: 3.42,
|
bytesInPerSec: 3.42,
|
||||||
bytesOutPerSec: 4.14,
|
bytesOutPerSec: 4.14,
|
||||||
|
features: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clustersPayload: Cluster[] = [
|
export const clustersPayload: Cluster[] = [
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { RootState } from 'redux/interfaces';
|
import { RootState } from 'redux/interfaces';
|
||||||
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
|
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
|
||||||
import { Cluster, ServerStatus } from 'generated-sources';
|
import { Cluster, ClusterFeaturesEnum, ServerStatus } from 'generated-sources';
|
||||||
|
|
||||||
const clustersState = ({ clusters }: RootState): Cluster[] => clusters;
|
const clustersState = ({ clusters }: RootState): Cluster[] => clusters;
|
||||||
|
|
||||||
|
@ -31,3 +31,10 @@ export const getClustersReadonlyStatus = (clusterName: string) =>
|
||||||
(clusters): boolean =>
|
(clusters): boolean =>
|
||||||
clusters.find(({ name }) => name === clusterName)?.readOnly || false
|
clusters.find(({ name }) => name === clusterName)?.readOnly || false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getClustersFeatures = (clusterName: string) =>
|
||||||
|
createSelector(
|
||||||
|
getClusterList,
|
||||||
|
(clusters): ClusterFeaturesEnum[] =>
|
||||||
|
clusters.find(({ name }) => name === clusterName)?.features || []
|
||||||
|
);
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import {
|
||||||
|
fetchConnectorsAction,
|
||||||
|
fetchConnectorAction,
|
||||||
|
fetchConnectsAction,
|
||||||
|
} from 'redux/actions';
|
||||||
|
import reducer, { initialState } from 'redux/reducers/connect/reducer';
|
||||||
|
|
||||||
|
describe('Clusters reducer', () => {
|
||||||
|
it('reacts on GET_CONNECTS__SUCCESS and returns payload', () => {
|
||||||
|
expect(
|
||||||
|
reducer(undefined, fetchConnectsAction.success(initialState))
|
||||||
|
).toEqual(initialState);
|
||||||
|
});
|
||||||
|
it('reacts on GET_CONNECTORS__SUCCESS and returns payload', () => {
|
||||||
|
expect(
|
||||||
|
reducer(undefined, fetchConnectorsAction.success(initialState))
|
||||||
|
).toEqual(initialState);
|
||||||
|
});
|
||||||
|
it('reacts on GET_CONNECTOR__SUCCESS and returns payload', () => {
|
||||||
|
expect(
|
||||||
|
reducer(undefined, fetchConnectorAction.success(initialState))
|
||||||
|
).toEqual(initialState);
|
||||||
|
});
|
||||||
|
});
|
22
kafka-ui-react-app/src/redux/reducers/connect/reducer.ts
Normal file
22
kafka-ui-react-app/src/redux/reducers/connect/reducer.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { getType } from 'typesafe-actions';
|
||||||
|
import * as actions from 'redux/actions';
|
||||||
|
import { ConnectState } from 'redux/interfaces/connect';
|
||||||
|
import { Action } from 'redux/interfaces';
|
||||||
|
|
||||||
|
export const initialState: ConnectState = {
|
||||||
|
connects: [],
|
||||||
|
connectors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const reducer = (state = initialState, action: Action): ConnectState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case getType(actions.fetchConnectsAction.success):
|
||||||
|
case getType(actions.fetchConnectorAction.success):
|
||||||
|
case getType(actions.fetchConnectorsAction.success):
|
||||||
|
return action.payload;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reducer;
|
35
kafka-ui-react-app/src/redux/reducers/connect/selectors.ts
Normal file
35
kafka-ui-react-app/src/redux/reducers/connect/selectors.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { ConnectState, RootState } from 'redux/interfaces';
|
||||||
|
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
|
||||||
|
|
||||||
|
const connectState = ({ connect }: RootState): ConnectState => connect;
|
||||||
|
|
||||||
|
const getConnectorsFetchingStatus = createFetchingSelector('GET_CONNECTORS');
|
||||||
|
export const getAreConnectorsFetching = createSelector(
|
||||||
|
getConnectorsFetchingStatus,
|
||||||
|
(status) => status === 'fetching'
|
||||||
|
);
|
||||||
|
export const getAreConnectorsFetched = createSelector(
|
||||||
|
getConnectorsFetchingStatus,
|
||||||
|
(status) => status === 'fetched'
|
||||||
|
);
|
||||||
|
|
||||||
|
const getConnectsFetchingStatus = createFetchingSelector('GET_CONNECTS');
|
||||||
|
export const getAreConnectsFetching = createSelector(
|
||||||
|
getConnectsFetchingStatus,
|
||||||
|
(status) => status === 'fetching' || status === 'notFetched'
|
||||||
|
);
|
||||||
|
export const getAreConnectsFetched = createSelector(
|
||||||
|
getConnectsFetchingStatus,
|
||||||
|
(status) => status === 'fetched'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConnects = createSelector(
|
||||||
|
connectState,
|
||||||
|
({ connects }) => connects
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConnectors = createSelector(
|
||||||
|
connectState,
|
||||||
|
({ connectors }) => connectors
|
||||||
|
);
|
|
@ -5,6 +5,7 @@ import clusters from './clusters/reducer';
|
||||||
import brokers from './brokers/reducer';
|
import brokers from './brokers/reducer';
|
||||||
import consumerGroups from './consumerGroups/reducer';
|
import consumerGroups from './consumerGroups/reducer';
|
||||||
import schemas from './schemas/reducer';
|
import schemas from './schemas/reducer';
|
||||||
|
import connect from './connect/reducer';
|
||||||
import loader from './loader/reducer';
|
import loader from './loader/reducer';
|
||||||
import alerts from './alerts/reducer';
|
import alerts from './alerts/reducer';
|
||||||
|
|
||||||
|
@ -14,6 +15,7 @@ export default combineReducers<RootState>({
|
||||||
brokers,
|
brokers,
|
||||||
consumerGroups,
|
consumerGroups,
|
||||||
schemas,
|
schemas,
|
||||||
|
connect,
|
||||||
loader,
|
loader,
|
||||||
alerts,
|
alerts,
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import configureMockStore, { MockStoreCreator } from 'redux-mock-store';
|
||||||
|
import thunk, { ThunkDispatch } from 'redux-thunk';
|
||||||
|
import { Middleware } from 'redux';
|
||||||
|
import { RootState, Action } from 'redux/interfaces';
|
||||||
|
|
||||||
|
const middlewares: Array<Middleware> = [thunk];
|
||||||
|
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
|
||||||
|
|
||||||
|
const mockStoreCreator: MockStoreCreator<
|
||||||
|
RootState,
|
||||||
|
DispatchExts
|
||||||
|
> = configureMockStore<RootState, DispatchExts>(middlewares);
|
||||||
|
|
||||||
|
export default mockStoreCreator();
|
Loading…
Add table
Reference in a new issue