Kafka Connect. Initial setup (#362)

* Refactor Nav. Use feature flags. Connect

* Refactor Alerts

* Kafka Connect initial setup
This commit is contained in:
Oleg Shur 2021-04-07 23:50:17 +03:00 committed by GitHub
parent dbadff8f2e
commit 9d75dbdacd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 931 additions and 190 deletions

View file

@ -1,29 +1,70 @@
import React from 'react';
import { useSelector } from 'react-redux';
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 ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
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 BrokersContainer from 'components/Brokers/BrokersContainer';
import ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
const Cluster: React.FC = () => {
const { clusterName } = useParams<{ clusterName: string }>();
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 (
<ClusterContext.Provider value={{ isReadOnly }}>
<ClusterContext.Provider value={contextValue}>
<Switch>
<Route
path="/ui/clusters/:clusterName/brokers"
path={clusterBrokersPath(':clusterName')}
component={BrokersContainer}
/>
<Route path="/ui/clusters/:clusterName/topics" component={Topics} />
<Route path={clusterTopicsPath(':clusterName')} component={Topics} />
<Route
path="/ui/clusters/:clusterName/consumer-groups"
path={clusterConsumerGroupsPath(':clusterName')}
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
from="/ui/clusters/:clusterName"
to="/ui/clusters/:clusterName/brokers"

View file

@ -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();
});
});
});

View 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;

View 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;

View file

@ -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);

View file

@ -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();
});
});
});
});

View file

@ -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 {
clusterBrokersPath,
clusterTopicsPath,
clusterConsumerGroupsPath,
clusterSchemasPath,
clusterConnectorsPath,
} from 'lib/paths';
import { Cluster, ServerStatus } from 'generated-sources';
import DefaultClusterIcon from './DefaultClusterIcon';
import ClusterStatusIcon from './ClusterStatusIcon';
interface Props {
cluster: Cluster;
}
const DefaultIcon: 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>
const ClusterMenu: React.FC<Props> = ({
cluster: { name, status, defaultCluster, features },
}) => {
const hasFeatureConfigured = React.useCallback(
(key) => features?.includes(key),
[features]
);
};
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}
to={clusterBrokersPath(name)}
title={name}
className="has-text-overflow-ellipsis"
>
{cluster.defaultCluster && <DefaultIcon />}
{cluster.name}
<StatusIcon cluster={cluster} />
{defaultCluster && <DefaultClusterIcon />}
{name}
<ClusterStatusIcon status={status} />
</NavLink>
<ul>
<li>
<NavLink
to={clusterBrokersPath(cluster.name)}
to={clusterBrokersPath(name)}
activeClassName="is-active"
title="Brokers"
>
Brokers
</NavLink>
</li>
<li>
<NavLink
to={clusterTopicsPath(cluster.name)}
to={clusterTopicsPath(name)}
activeClassName="is-active"
title="Topics"
>
Topics
</NavLink>
</li>
<li>
<NavLink
to={clusterConsumerGroupsPath(cluster.name)}
to={clusterConsumerGroupsPath(name)}
activeClassName="is-active"
title="Consumers"
>
Consumers
</NavLink>
</li>
{hasFeatureConfigured(ClusterFeaturesEnum.SCHEMA_REGISTRY) && (
<li>
<NavLink
to={clusterSchemasPath(cluster.name)}
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>
);
);
};
export default ClusterMenu;

View 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;

View 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;

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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>
`;

View file

@ -110,7 +110,13 @@ describe('Details', () => {
expect(
mount(
<StaticRouter>
<ClusterContext.Provider value={{ isReadOnly: true }}>
<ClusterContext.Provider
value={{
isReadOnly: true,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
}}
>
{setupWrapper({ versions })}
</ClusterContext.Provider>
</StaticRouter>

View file

@ -91,7 +91,13 @@ describe('List', () => {
describe('with readonly cluster', () => {
const wrapper = mount(
<StaticRouter>
<ClusterContext.Provider value={{ isReadOnly: true }}>
<ClusterContext.Provider
value={{
isReadOnly: true,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
}}
>
{setupWrapper({ schemas: [] })}
</ClusterContext.Provider>
</StaticRouter>

View file

@ -9,7 +9,13 @@ describe('List', () => {
it('does not render the Add a Topic button', () => {
const component = mount(
<StaticRouter>
<ClusterContext.Provider value={{ isReadOnly: true }}>
<ClusterContext.Provider
value={{
isReadOnly: true,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
}}
>
<List
areTopicsFetching={false}
topics={[]}
@ -29,7 +35,13 @@ describe('List', () => {
it('renders the Add a Topic button', () => {
const component = mount(
<StaticRouter>
<ClusterContext.Provider value={{ isReadOnly: false }}>
<ClusterContext.Provider
value={{
isReadOnly: false,
hasKafkaConnectConfigured: true,
hasSchemaRegistryConfigured: true,
}}
>
<List
areTopicsFetching={false}
topics={[]}

View file

@ -2,17 +2,32 @@ import React from 'react';
import cx from 'classnames';
interface Props {
fetching?: boolean;
label: string;
title?: string;
className?: string;
}
const Indicator: React.FC<Props> = ({ label, title, className, children }) => {
const Indicator: React.FC<Props> = ({
label,
title,
fetching,
className,
children,
}) => {
return (
<div className={cx('level-item', 'level-left', className)}>
<div title={title || label}>
<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>
);

View file

@ -17,7 +17,7 @@ exports[`Indicator matches the snapshot 1`] = `
label
</p>
<p
className="title"
className="title has-text-centered"
>
Child
</p>

View file

@ -1,7 +1,15 @@
import React from 'react';
const initialValue: { isReadOnly: boolean } = {
export interface ContextProps {
isReadOnly: boolean;
hasKafkaConnectConfigured: boolean;
hasSchemaRegistryConfigured: boolean;
}
export const initialValue: ContextProps = {
isReadOnly: false,
hasKafkaConnectConfigured: false,
hasSchemaRegistryConfigured: false,
};
const ClusterContext = React.createContext(initialValue);

View file

@ -66,4 +66,10 @@ describe('Paths', () => {
'/ui/clusters/local/topics/topic123/edit'
);
});
it('clusterConnectorsPath', () => {
expect(paths.clusterConnectorsPath('local')).toEqual(
'/ui/clusters/local/connectors'
);
});
});

View file

@ -45,3 +45,7 @@ export const clusterTopicsTopicEditPath = (
clusterName: ClusterName,
topicName: TopicName
) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;
// Kafka Connect
export const clusterConnectorsPath = (clusterName: ClusterName) =>
`${clusterPath(clusterName)}/connectors`;

View file

@ -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,
},
},
}),
]);
});
});
});

View file

@ -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 { Middleware } from 'redux';
import { RootState, Action } from 'redux/interfaces';
import * as actions from 'redux/actions/actions';
import * as thunks from 'redux/actions/thunks';
import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
import * as fixtures from '../fixtures';
const middlewares: Array<Middleware> = [thunk];
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
const mockStoreCreator: MockStoreCreator<
RootState,
DispatchExts
> = configureMockStore<RootState, DispatchExts>(middlewares);
const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
const store = mockStoreCreator;
const clusterName = 'local';
const subject = 'test';

View file

@ -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 { Middleware } from 'redux';
import { RootState, Action } from 'redux/interfaces';
import * as actions from 'redux/actions/actions';
import * as thunks from 'redux/actions/thunks';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
const middlewares: Array<Middleware> = [thunk];
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
const mockStoreCreator: MockStoreCreator<
RootState,
DispatchExts
> = configureMockStore<RootState, DispatchExts>(middlewares);
const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
const store = mockStoreCreator;
const clusterName = 'local';
const topicName = 'localTopic';

View file

@ -4,8 +4,8 @@ import {
FailurePayload,
TopicName,
TopicsState,
ConnectState,
} from 'redux/interfaces';
import {
Cluster,
ClusterStats,
@ -125,3 +125,21 @@ export const createSchemaAction = createAsyncAction(
)<undefined, SchemaSubject, { alert?: FailurePayload }>();
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 }>();

View 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 }));
}
};

View file

@ -3,3 +3,4 @@ export * from './clusters';
export * from './consumerGroups';
export * from './schemas';
export * from './topics';
export * from './connectors';

View file

@ -61,8 +61,7 @@ export const createSchema = (
} catch (error) {
const response = await getResponse(error);
const alert: FailurePayload = {
subject: 'schema',
subjectId: newSchemaSubject.subject,
subject: ['schema', newSchemaSubject.subject].join('-'),
title: `Schema ${newSchemaSubject.subject}`,
response,
};

View file

@ -232,8 +232,7 @@ export const createTopic = (
} catch (error) {
const response = await getResponse(error);
const alert: FailurePayload = {
subjectId: form.name,
subject: 'schema',
subject: ['schema', form.name].join('-'),
title: `Schema ${form.name}`,
response,
};

View file

@ -4,14 +4,13 @@ import React from 'react';
export interface ServerResponse {
status: number;
statusText: string;
body: ErrorResponse;
body?: ErrorResponse;
}
export interface FailurePayload {
title: string;
message?: string;
subject: string;
subjectId?: string | number;
response?: ServerResponse;
}

View file

@ -0,0 +1,6 @@
import { Connect, Connector } from 'generated-sources';
export interface ConnectState {
connects: Connect[];
connectors: Connector[];
}

View file

@ -8,6 +8,7 @@ import { LoaderState } from './loader';
import { ConsumerGroupsState } from './consumerGroup';
import { SchemasState } from './schema';
import { AlertsState } from './alerts';
import { ConnectState } from './connect';
export * from './topic';
export * from './cluster';
@ -16,6 +17,7 @@ export * from './consumerGroup';
export * from './schema';
export * from './loader';
export * from './alerts';
export * from './connect';
export interface RootState {
topics: TopicsState;
@ -23,6 +25,7 @@ export interface RootState {
brokers: BrokersState;
consumerGroups: ConsumerGroupsState;
schemas: SchemasState;
connect: ConnectState;
loader: LoaderState;
alerts: AlertsState;
}

View file

@ -1,10 +1,10 @@
export const failurePayloadWithoutId = {
export const failurePayload1 = {
title: 'title',
message: 'message',
subject: 'topic',
subject: 'topic-1',
};
export const failurePayloadWithId = {
...failurePayloadWithoutId,
subjectId: '12345',
export const failurePayload2 = {
...failurePayload1,
subject: 'topic-2',
};

View file

@ -1,6 +1,6 @@
import { dismissAlert, createTopicAction } from 'redux/actions';
import reducer from 'redux/reducers/alerts/reducer';
import { failurePayloadWithId, failurePayloadWithoutId } from './fixtures';
import { failurePayload1, failurePayload2 } from './fixtures';
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
@ -12,38 +12,18 @@ describe('Clusters reducer', () => {
expect(reducer(undefined, createTopicAction.failure({}))).toEqual({});
});
it('creates error alert with subjectId', () => {
it('creates error alert', () => {
expect(
reducer(
undefined,
createTopicAction.failure({
alert: failurePayloadWithId,
alert: failurePayload2,
})
)
).toEqual({
'alert-topic12345': {
'alert-topic-2': {
createdAt: 1234567890,
id: 'alert-topic12345',
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',
id: 'alert-topic-2',
message: 'message',
response: undefined,
title: 'title',
@ -56,23 +36,23 @@ describe('Clusters reducer', () => {
const state = reducer(
undefined,
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', () => {
const state = reducer(
undefined,
createTopicAction.failure({
alert: failurePayloadWithoutId,
alert: failurePayload1,
})
);
expect(reducer(state, dismissAlert('wrong-id'))).toEqual({
'alert-topic': {
'alert-topic-1': {
createdAt: 1234567890,
id: 'alert-topic',
id: 'alert-topic-1',
message: 'message',
response: undefined,
title: 'title',

View file

@ -1,7 +1,7 @@
import configureStore from 'redux/store/configureStore';
import { createTopicAction } from 'redux/actions';
import * as selectors from '../selectors';
import { failurePayloadWithId, failurePayloadWithoutId } from './fixtures';
import { failurePayload1, failurePayload2 } from './fixtures';
const store = configureStore();
@ -14,12 +14,8 @@ describe('Alerts selectors', () => {
describe('state', () => {
beforeAll(() => {
store.dispatch(
createTopicAction.failure({ alert: failurePayloadWithoutId })
);
store.dispatch(
createTopicAction.failure({ alert: failurePayloadWithId })
);
store.dispatch(createTopicAction.failure({ alert: failurePayload1 }));
store.dispatch(createTopicAction.failure({ alert: failurePayload2 }));
});
it('returns fetch status', () => {

View file

@ -8,15 +8,9 @@ export const addError = (state: AlertsState, action: Action) => {
'alert' in action.payload &&
action.payload.alert !== undefined
) {
const {
subject,
subjectId,
title,
message,
response,
} = action.payload.alert;
const { subject, title, message, response } = action.payload.alert;
const id = `alert-${subject}${subjectId || ''}`;
const id = `alert-${subject}`;
return {
...state,

View file

@ -9,6 +9,7 @@ export const onlineClusterPayload: Cluster = {
topicCount: 3,
bytesInPerSec: 1.55,
bytesOutPerSec: 9.314,
features: [],
};
export const offlineClusterPayload: Cluster = {
name: 'local',
@ -19,6 +20,7 @@ export const offlineClusterPayload: Cluster = {
topicCount: 2,
bytesInPerSec: 3.42,
bytesOutPerSec: 4.14,
features: [],
};
export const clustersPayload: Cluster[] = [

View file

@ -1,7 +1,7 @@
import { createSelector } from 'reselect';
import { RootState } from 'redux/interfaces';
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;
@ -31,3 +31,10 @@ export const getClustersReadonlyStatus = (clusterName: string) =>
(clusters): boolean =>
clusters.find(({ name }) => name === clusterName)?.readOnly || false
);
export const getClustersFeatures = (clusterName: string) =>
createSelector(
getClusterList,
(clusters): ClusterFeaturesEnum[] =>
clusters.find(({ name }) => name === clusterName)?.features || []
);

View file

@ -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);
});
});

View 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;

View 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
);

View file

@ -5,6 +5,7 @@ import clusters from './clusters/reducer';
import brokers from './brokers/reducer';
import consumerGroups from './consumerGroups/reducer';
import schemas from './schemas/reducer';
import connect from './connect/reducer';
import loader from './loader/reducer';
import alerts from './alerts/reducer';
@ -14,6 +15,7 @@ export default combineReducers<RootState>({
brokers,
consumerGroups,
schemas,
connect,
loader,
alerts,
});

View file

@ -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();