瀏覽代碼

Kafka Connect. Initial setup (#362)

* Refactor Nav. Use feature flags. Connect

* Refactor Alerts

* Kafka Connect initial setup
Oleg Shur 4 年之前
父節點
當前提交
9d75dbdacd
共有 42 個文件被更改,包括 929 次插入188 次删除
  1. 49 8
      kafka-ui-react-app/src/components/Cluster/Cluster.tsx
  2. 85 0
      kafka-ui-react-app/src/components/Cluster/__tests__/Cluster.spec.tsx
  3. 16 0
      kafka-ui-react-app/src/components/Connect/Connect.tsx
  4. 87 0
      kafka-ui-react-app/src/components/Connect/List/List.tsx
  5. 28 0
      kafka-ui-react-app/src/components/Connect/List/ListContainer.ts
  6. 88 0
      kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx
  7. 76 82
      kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx
  8. 28 0
      kafka-ui-react-app/src/components/Nav/ClusterStatusIcon.tsx
  9. 22 0
      kafka-ui-react-app/src/components/Nav/DefaultClusterIcon.tsx
  10. 36 0
      kafka-ui-react-app/src/components/Nav/__tests__/ClusterMenu.spec.tsx
  11. 22 0
      kafka-ui-react-app/src/components/Nav/__tests__/ClusterStatusIcon.spec.tsx
  12. 21 0
      kafka-ui-react-app/src/components/Nav/__tests__/__snapshots__/ClusterStatusIcon.spec.tsx.snap
  13. 7 1
      kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx
  14. 7 1
      kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx
  15. 14 2
      kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
  16. 17 2
      kafka-ui-react-app/src/components/common/Dashboard/Indicator.tsx
  17. 1 1
      kafka-ui-react-app/src/components/common/Dashboard/__tests__/__snapshots__/Indicator.spec.tsx.snap
  18. 9 1
      kafka-ui-react-app/src/components/contexts/ClusterContext.ts
  19. 6 0
      kafka-ui-react-app/src/lib/__tests__/paths.spec.ts
  20. 4 0
      kafka-ui-react-app/src/lib/paths.ts
  21. 49 0
      kafka-ui-react-app/src/redux/actions/__test__/thunks/connectors.spec.ts
  22. 2 16
      kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts
  23. 2 16
      kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts
  24. 19 1
      kafka-ui-react-app/src/redux/actions/actions.ts
  25. 94 0
      kafka-ui-react-app/src/redux/actions/thunks/connectors.ts
  26. 1 0
      kafka-ui-react-app/src/redux/actions/thunks/index.ts
  27. 1 2
      kafka-ui-react-app/src/redux/actions/thunks/schemas.ts
  28. 1 2
      kafka-ui-react-app/src/redux/actions/thunks/topics.ts
  29. 1 2
      kafka-ui-react-app/src/redux/interfaces/alerts.ts
  30. 6 0
      kafka-ui-react-app/src/redux/interfaces/connect.ts
  31. 3 0
      kafka-ui-react-app/src/redux/interfaces/index.ts
  32. 5 5
      kafka-ui-react-app/src/redux/reducers/alerts/__test__/fixtures.ts
  33. 10 30
      kafka-ui-react-app/src/redux/reducers/alerts/__test__/reducer.spec.ts
  34. 3 7
      kafka-ui-react-app/src/redux/reducers/alerts/__test__/selectors.spec.ts
  35. 2 8
      kafka-ui-react-app/src/redux/reducers/alerts/utils.ts
  36. 2 0
      kafka-ui-react-app/src/redux/reducers/clusters/__test__/fixtures.ts
  37. 8 1
      kafka-ui-react-app/src/redux/reducers/clusters/selectors.ts
  38. 24 0
      kafka-ui-react-app/src/redux/reducers/connect/__tests__/reducer.spec.ts
  39. 22 0
      kafka-ui-react-app/src/redux/reducers/connect/reducer.ts
  40. 35 0
      kafka-ui-react-app/src/redux/reducers/connect/selectors.ts
  41. 2 0
      kafka-ui-react-app/src/redux/reducers/index.ts
  42. 14 0
      kafka-ui-react-app/src/redux/store/configureStore/mockStoreCreator.ts

+ 49 - 8
kafka-ui-react-app/src/components/Cluster/Cluster.tsx

@@ -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"

+ 85 - 0
kafka-ui-react-app/src/components/Cluster/__tests__/Cluster.spec.tsx

@@ -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 - 0
kafka-ui-react-app/src/components/Connect/Connect.tsx

@@ -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 - 0
kafka-ui-react-app/src/components/Connect/List/List.tsx

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

+ 28 - 0
kafka-ui-react-app/src/components/Connect/List/ListContainer.ts

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

+ 88 - 0
kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx

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

+ 76 - 82
kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx

@@ -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}
-        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>
+    <ul className="menu-list">
+      <li>
         <NavLink
-          to={clusterSchemasPath(cluster.name)}
-          activeClassName="is-active"
-          title="Schema Registry"
+          exact
+          to={clusterBrokersPath(name)}
+          title={name}
+          className="has-text-overflow-ellipsis"
         >
-          Schema Registry
+          {defaultCluster && <DefaultClusterIcon />}
+          {name}
+          <ClusterStatusIcon status={status} />
         </NavLink>
-      </ul>
-    </li>
-  </ul>
-);
+        <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>
+  );
+};
 
 export default ClusterMenu;

+ 28 - 0
kafka-ui-react-app/src/components/Nav/ClusterStatusIcon.tsx

@@ -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 - 0
kafka-ui-react-app/src/components/Nav/DefaultClusterIcon.tsx

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

+ 36 - 0
kafka-ui-react-app/src/components/Nav/__tests__/ClusterMenu.spec.tsx

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

+ 22 - 0
kafka-ui-react-app/src/components/Nav/__tests__/ClusterStatusIcon.spec.tsx

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

+ 21 - 0
kafka-ui-react-app/src/components/Nav/__tests__/__snapshots__/ClusterStatusIcon.spec.tsx.snap

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

+ 7 - 1
kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx

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

+ 7 - 1
kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx

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

+ 14 - 2
kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx

@@ -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={[]}

+ 17 - 2
kafka-ui-react-app/src/components/common/Dashboard/Indicator.tsx

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

+ 1 - 1
kafka-ui-react-app/src/components/common/Dashboard/__tests__/__snapshots__/Indicator.spec.tsx.snap

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

+ 9 - 1
kafka-ui-react-app/src/components/contexts/ClusterContext.ts

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

+ 6 - 0
kafka-ui-react-app/src/lib/__tests__/paths.spec.ts

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

+ 4 - 0
kafka-ui-react-app/src/lib/paths.ts

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

+ 49 - 0
kafka-ui-react-app/src/redux/actions/__test__/thunks/connectors.spec.ts

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

+ 2 - 16
kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts

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

+ 2 - 16
kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts

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

+ 19 - 1
kafka-ui-react-app/src/redux/actions/actions.ts

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

+ 94 - 0
kafka-ui-react-app/src/redux/actions/thunks/connectors.ts

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

+ 1 - 0
kafka-ui-react-app/src/redux/actions/thunks/index.ts

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

+ 1 - 2
kafka-ui-react-app/src/redux/actions/thunks/schemas.ts

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

+ 1 - 2
kafka-ui-react-app/src/redux/actions/thunks/topics.ts

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

+ 1 - 2
kafka-ui-react-app/src/redux/interfaces/alerts.ts

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

+ 6 - 0
kafka-ui-react-app/src/redux/interfaces/connect.ts

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

+ 3 - 0
kafka-ui-react-app/src/redux/interfaces/index.ts

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

+ 5 - 5
kafka-ui-react-app/src/redux/reducers/alerts/__test__/fixtures.ts

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

+ 10 - 30
kafka-ui-react-app/src/redux/reducers/alerts/__test__/reducer.spec.ts

@@ -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',

+ 3 - 7
kafka-ui-react-app/src/redux/reducers/alerts/__test__/selectors.spec.ts

@@ -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', () => {

+ 2 - 8
kafka-ui-react-app/src/redux/reducers/alerts/utils.ts

@@ -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,

+ 2 - 0
kafka-ui-react-app/src/redux/reducers/clusters/__test__/fixtures.ts

@@ -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[] = [

+ 8 - 1
kafka-ui-react-app/src/redux/reducers/clusters/selectors.ts

@@ -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 || []
+  );

+ 24 - 0
kafka-ui-react-app/src/redux/reducers/connect/__tests__/reducer.spec.ts

@@ -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 - 0
kafka-ui-react-app/src/redux/reducers/connect/reducer.ts

@@ -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 - 0
kafka-ui-react-app/src/redux/reducers/connect/selectors.ts

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

+ 2 - 0
kafka-ui-react-app/src/redux/reducers/index.ts

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

+ 14 - 0
kafka-ui-react-app/src/redux/store/configureStore/mockStoreCreator.ts

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