Browse Source

Add connect views

Marat Adiyatullin 4 years ago
parent
commit
16e63f2c35
70 changed files with 29447 additions and 169 deletions
  1. 1 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/mapper/KafkaConnectMapper.java
  2. 23 5
      kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
  3. 24294 1
      kafka-ui-react-app/package-lock.json
  4. 6 2
      kafka-ui-react-app/package.json
  5. 7 0
      kafka-ui-react-app/src/components/Cluster/Cluster.tsx
  6. 71 0
      kafka-ui-react-app/src/components/Connect/Breadcrumbs/Breadcrumbs.tsx
  7. 65 0
      kafka-ui-react-app/src/components/Connect/Breadcrumbs/__tests__/Breadcrumbs.spec.tsx
  8. 109 0
      kafka-ui-react-app/src/components/Connect/Breadcrumbs/__tests__/__snapshots__/Breadcrumbs.spec.tsx.snap
  9. 57 9
      kafka-ui-react-app/src/components/Connect/Connect.tsx
  10. 168 0
      kafka-ui-react-app/src/components/Connect/Details/Actions/Actions.tsx
  11. 33 0
      kafka-ui-react-app/src/components/Connect/Details/Actions/ActionsContainer.ts
  12. 188 0
      kafka-ui-react-app/src/components/Connect/Details/Actions/__tests__/Actions.spec.tsx
  13. 483 0
      kafka-ui-react-app/src/components/Connect/Details/Actions/__tests__/__snapshots__/Actions.spec.tsx.snap
  14. 57 0
      kafka-ui-react-app/src/components/Connect/Details/Config/Config.tsx
  15. 21 0
      kafka-ui-react-app/src/components/Connect/Details/Config/ConfigContainer.ts
  16. 68 0
      kafka-ui-react-app/src/components/Connect/Details/Config/__test__/Config.spec.tsx
  17. 20 0
      kafka-ui-react-app/src/components/Connect/Details/Config/__test__/__snapshots__/Config.spec.tsx.snap
  18. 141 0
      kafka-ui-react-app/src/components/Connect/Details/Details.tsx
  19. 28 0
      kafka-ui-react-app/src/components/Connect/Details/DetailsContainer.ts
  20. 58 0
      kafka-ui-react-app/src/components/Connect/Details/Overview/Overview.tsx
  21. 18 0
      kafka-ui-react-app/src/components/Connect/Details/Overview/OverviewContainer.ts
  22. 35 0
      kafka-ui-react-app/src/components/Connect/Details/Overview/__tests__/Overview.spec.tsx
  23. 66 0
      kafka-ui-react-app/src/components/Connect/Details/Overview/__tests__/__snapshots__/Overview.spec.tsx.snap
  24. 57 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/ListItem.tsx
  25. 23 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/ListItemContainer.ts
  26. 68 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/__tests__/ListItem.spec.tsx
  27. 40 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/__tests__/__snapshots__/ListItem.spec.tsx.snap
  28. 68 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/Tasks.tsx
  29. 21 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/TasksContainer.ts
  30. 71 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/Tasks.spec.tsx
  31. 138 0
      kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/__snapshots__/Tasks.spec.tsx.snap
  32. 104 0
      kafka-ui-react-app/src/components/Connect/Details/__tests__/Details.spec.tsx
  33. 105 0
      kafka-ui-react-app/src/components/Connect/Details/__tests__/__snapshots__/Details.spec.tsx.snap
  34. 142 0
      kafka-ui-react-app/src/components/Connect/Edit/Edit.tsx
  35. 22 0
      kafka-ui-react-app/src/components/Connect/Edit/EditContainer.ts
  36. 115 0
      kafka-ui-react-app/src/components/Connect/Edit/__tests__/Edit.spec.tsx
  37. 50 0
      kafka-ui-react-app/src/components/Connect/Edit/__tests__/__snapshots__/Edit.spec.tsx.snap
  38. 11 13
      kafka-ui-react-app/src/components/Connect/List/List.tsx
  39. 13 4
      kafka-ui-react-app/src/components/Connect/List/ListItem.tsx
  40. 4 4
      kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx
  41. 28 3
      kafka-ui-react-app/src/components/Connect/List/__tests__/ListItem.spec.tsx
  42. 38 2
      kafka-ui-react-app/src/components/Connect/List/__tests__/__snapshots__/ListItem.spec.tsx.snap
  43. 179 0
      kafka-ui-react-app/src/components/Connect/New/New.tsx
  44. 22 0
      kafka-ui-react-app/src/components/Connect/New/NewContainer.ts
  45. 117 0
      kafka-ui-react-app/src/components/Connect/New/__tests__/New.spec.tsx
  46. 103 0
      kafka-ui-react-app/src/components/Connect/New/__tests__/__snapshots__/New.spec.tsx.snap
  47. 2 2
      kafka-ui-react-app/src/components/Connect/StatusTag.tsx
  48. 10 0
      kafka-ui-react-app/src/components/Connect/__tests__/Connect.spec.tsx
  49. 38 0
      kafka-ui-react-app/src/components/Connect/__tests__/StatusTag.spec.tsx
  50. 46 0
      kafka-ui-react-app/src/components/Connect/__tests__/__snapshots__/Connect.spec.tsx.snap
  51. 33 0
      kafka-ui-react-app/src/components/Connect/__tests__/__snapshots__/StatusTag.spec.tsx.snap
  52. 5 0
      kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx
  53. 22 3
      kafka-ui-react-app/src/components/common/ConfirmationModal/ConfirmationModal.tsx
  54. 35 18
      kafka-ui-react-app/src/components/common/ConfirmationModal/__test__/ConfirmationModal.spec.tsx
  55. 24 16
      kafka-ui-react-app/src/components/common/JSONEditor/JSONEditor.tsx
  56. 12 0
      kafka-ui-react-app/src/components/common/JSONEditor/__tests__/JSONEditor.spec.tsx
  57. 83 0
      kafka-ui-react-app/src/components/common/JSONEditor/__tests__/__snapshots__/JSONEditor.spec.tsx.snap
  58. 24 0
      kafka-ui-react-app/src/lib/__test__/yupExtended.spec.ts
  59. 50 1
      kafka-ui-react-app/src/lib/paths.ts
  60. 55 0
      kafka-ui-react-app/src/lib/testHelpers.tsx
  61. 41 0
      kafka-ui-react-app/src/lib/yupExtended.ts
  62. 433 28
      kafka-ui-react-app/src/redux/actions/__test__/thunks/connectors.spec.ts
  63. 58 5
      kafka-ui-react-app/src/redux/actions/actions.ts
  64. 255 19
      kafka-ui-react-app/src/redux/actions/thunks/connectors.ts
  65. 12 1
      kafka-ui-react-app/src/redux/interfaces/connect.ts
  66. 145 6
      kafka-ui-react-app/src/redux/reducers/connect/__test__/fixtures.ts
  67. 214 9
      kafka-ui-react-app/src/redux/reducers/connect/__test__/reducer.spec.ts
  68. 92 0
      kafka-ui-react-app/src/redux/reducers/connect/__test__/selectors.spec.ts
  69. 100 2
      kafka-ui-react-app/src/redux/reducers/connect/reducer.ts
  70. 102 15
      kafka-ui-react-app/src/redux/reducers/connect/selectors.ts

+ 1 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/mapper/KafkaConnectMapper.java

@@ -53,7 +53,7 @@ public interface KafkaConnectMapper {
         .type(triple.getLeft().getType())
         .topics(getTopicsFromConfig.apply(triple.getMiddle()))
         .status(
-            triple.getLeft().getStatus().getState()
+            triple.getLeft().getStatus()
         )
         .tasksCount(triple.getRight().size())
         .failedTasksCount((int) triple.getRight().stream()

+ 23 - 5
kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml

@@ -1615,11 +1615,13 @@ components:
           type: string
         address:
           type: string
+      required:
+        - name
 
     ConnectorConfig:
       type: object
       additionalProperties:
-        type: object
+        type: string
 
     TaskId:
       type: object
@@ -1638,6 +1640,8 @@ components:
           $ref: '#/components/schemas/TaskStatus'
         config:
           $ref: '#/components/schemas/ConnectorConfig'
+      required:
+        - status
 
     NewConnector:
       type: object
@@ -1665,6 +1669,10 @@ components:
               $ref: '#/components/schemas/ConnectorStatus'
             connect:
               type: string
+          required:
+            - type
+            - status
+            - connect
 
     ConnectorType:
       type: string
@@ -1683,6 +1691,10 @@ components:
           type: string
         trace:
           type: string
+      required:
+        - id
+        - state
+        - worker_id
 
     ConnectorStatus:
       type: object
@@ -1691,6 +1703,8 @@ components:
           $ref: '#/components/schemas/ConnectorTaskStatus'
         worker_id:
           type: string
+      required:
+        - state
 
     ConnectorTaskStatus:
       type: string
@@ -1703,9 +1717,9 @@ components:
     ConnectorAction:
       type: string
       enum:
-        - restart
-        - pause
-        - resume
+        - RESTART
+        - PAUSE
+        - RESUME
 
     TaskAction:
       type: string
@@ -1823,8 +1837,12 @@ components:
           items:
             type: string
         status:
-          $ref: '#/components/schemas/ConnectorTaskStatus'
+          $ref: '#/components/schemas/ConnectorStatus'
         tasks_count:
           type: integer
         failed_tasks_count:
           type: integer
+      required:
+        - name
+        - connect
+        - status

File diff suppressed because it is too large
+ 24294 - 1
kafka-ui-react-app/package-lock.json


+ 6 - 2
kafka-ui-react-app/package.json

@@ -5,8 +5,9 @@
   "dependencies": {
     "@fortawesome/fontawesome-free": "^5.15.3",
     "@hookform/error-message": "0.0.5",
-    "ace-builds": "^1.4.12",
+    "@hookform/resolvers": "^1.3.7",
     "@rooks/use-outside-click-ref": "^4.10.1",
+    "ace-builds": "^1.4.12",
     "bulma": "^0.9.2",
     "bulma-switch": "^2.0.0",
     "classnames": "^2.2.6",
@@ -30,7 +31,8 @@
     "reselect": "^4.0.0",
     "typesafe-actions": "^5.1.0",
     "use-debounce": "^6.0.1",
-    "uuid": "^8.3.1"
+    "uuid": "^8.3.1",
+    "yup": "^0.32.9"
   },
   "lint-staged": {
     "*.{js,ts,jsx,tsx}": [
@@ -81,6 +83,7 @@
     "@types/react-dom": "^17.0.2",
     "@types/react-redux": "^7.1.11",
     "@types/react-router-dom": "^5.1.6",
+    "@types/react-test-renderer": "^17.0.1",
     "@types/redux-mock-store": "^1.0.2",
     "@types/uuid": "^8.3.0",
     "@typescript-eslint/eslint-plugin": "^4.20.0",
@@ -106,6 +109,7 @@
     "node-sass": "^5.0.0",
     "prettier": "^2.2.1",
     "react-scripts": "4.0.3",
+    "react-test-renderer": "^17.0.2",
     "redux-mock-store": "^1.5.4",
     "ts-jest": "^26.5.4",
     "ts-node": "^9.1.1",

+ 7 - 0
kafka-ui-react-app/src/components/Cluster/Cluster.tsx

@@ -9,6 +9,7 @@ import {
 import {
   clusterBrokersPath,
   clusterConnectorsPath,
+  clusterConnectsPath,
   clusterConsumerGroupsPath,
   clusterSchemasPath,
   clusterTopicsPath,
@@ -59,6 +60,12 @@ const Cluster: React.FC = () => {
             component={Schemas}
           />
         )}
+        {hasKafkaConnectConfigured && (
+          <Route
+            path={clusterConnectsPath(':clusterName')}
+            component={Connect}
+          />
+        )}
         {hasKafkaConnectConfigured && (
           <Route
             path={clusterConnectorsPath(':clusterName')}

+ 71 - 0
kafka-ui-react-app/src/components/Connect/Breadcrumbs/Breadcrumbs.tsx

@@ -0,0 +1,71 @@
+import React from 'react';
+import { Route, Switch, useParams } from 'react-router-dom';
+import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
+import {
+  clusterConnectorsPath,
+  clusterConnectorNewPath,
+  clusterConnectConnectorPath,
+  clusterConnectConnectorEditPath,
+} from 'lib/paths';
+import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+const Breadcrumbs: React.FC = () => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+
+  const rootLinks = [
+    {
+      href: clusterConnectorsPath(clusterName),
+      label: 'All Connectors',
+    },
+  ];
+
+  const connectorLinks = [
+    ...rootLinks,
+    {
+      href: clusterConnectConnectorPath(
+        clusterName,
+        connectName,
+        connectorName
+      ),
+      label: connectorName,
+    },
+  ];
+
+  return (
+    <Switch>
+      <Route exact path={clusterConnectorsPath(':clusterName')}>
+        <Breadcrumb>All Connectors</Breadcrumb>
+      </Route>
+      <Route exact path={clusterConnectorNewPath(':clusterName')}>
+        <Breadcrumb links={rootLinks}>New Connector</Breadcrumb>
+      </Route>
+      <Route
+        exact
+        path={clusterConnectConnectorEditPath(
+          ':clusterName',
+          ':connectName',
+          ':connectorName'
+        )}
+      >
+        <Breadcrumb links={connectorLinks}>Edit</Breadcrumb>
+      </Route>
+      <Route
+        path={clusterConnectConnectorPath(
+          ':clusterName',
+          ':connectName',
+          ':connectorName'
+        )}
+      >
+        <Breadcrumb links={rootLinks}>{connectorName}</Breadcrumb>
+      </Route>
+    </Switch>
+  );
+};
+
+export default Breadcrumbs;

+ 65 - 0
kafka-ui-react-app/src/components/Connect/Breadcrumbs/__tests__/Breadcrumbs.spec.tsx

@@ -0,0 +1,65 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import Breadcrumbs from 'components/Connect/Breadcrumbs/Breadcrumbs';
+import {
+  clusterConnectConnectorEditPath,
+  clusterConnectConnectorPath,
+  clusterConnectorNewPath,
+  clusterConnectorsPath,
+} from 'lib/paths';
+import { TestRouterWrapper } from 'lib/testHelpers';
+
+describe('Breadcrumbs', () => {
+  const setupWrapper = (pathname: string) => (
+    <TestRouterWrapper
+      pathname={pathname}
+      urlParams={{
+        clusterName: 'my-cluster',
+        connectName: 'my-connect',
+        connectorName: 'my-connector',
+      }}
+    >
+      <Breadcrumbs />
+    </TestRouterWrapper>
+  );
+
+  it('matches snapshot for root path', () => {
+    expect(
+      create(setupWrapper(clusterConnectorsPath(':clusterName'))).toJSON()
+    ).toMatchSnapshot();
+  });
+
+  it('matches snapshot for new connector path', () => {
+    expect(
+      create(setupWrapper(clusterConnectorNewPath(':clusterName'))).toJSON()
+    ).toMatchSnapshot();
+  });
+
+  it('matches snapshot for connector edit path', () => {
+    expect(
+      create(
+        setupWrapper(
+          clusterConnectConnectorEditPath(
+            ':clusterName',
+            ':connectName',
+            ':connectorName'
+          )
+        )
+      ).toJSON()
+    ).toMatchSnapshot();
+  });
+
+  it('matches snapshot for connector path', () => {
+    expect(
+      create(
+        setupWrapper(
+          clusterConnectConnectorPath(
+            ':clusterName',
+            ':connectName',
+            ':connectorName'
+          )
+        )
+      ).toJSON()
+    ).toMatchSnapshot();
+  });
+});

+ 109 - 0
kafka-ui-react-app/src/components/Connect/Breadcrumbs/__tests__/__snapshots__/Breadcrumbs.spec.tsx.snap

@@ -0,0 +1,109 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Breadcrumbs matches snapshot for connector edit path 1`] = `
+<nav
+  aria-label="breadcrumbs"
+  className="breadcrumb"
+>
+  <ul>
+    <li>
+      <a
+        href="/ui/clusters/my-cluster/connectors"
+        onClick={[Function]}
+      >
+        All Connectors
+      </a>
+    </li>
+    <li>
+      <a
+        href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector"
+        onClick={[Function]}
+      >
+        my-connector
+      </a>
+    </li>
+    <li
+      className="is-active"
+    >
+      <span
+        className=""
+      >
+        Edit
+      </span>
+    </li>
+  </ul>
+</nav>
+`;
+
+exports[`Breadcrumbs matches snapshot for connector path 1`] = `
+<nav
+  aria-label="breadcrumbs"
+  className="breadcrumb"
+>
+  <ul>
+    <li>
+      <a
+        href="/ui/clusters/my-cluster/connectors"
+        onClick={[Function]}
+      >
+        All Connectors
+      </a>
+    </li>
+    <li
+      className="is-active"
+    >
+      <span
+        className=""
+      >
+        my-connector
+      </span>
+    </li>
+  </ul>
+</nav>
+`;
+
+exports[`Breadcrumbs matches snapshot for new connector path 1`] = `
+<nav
+  aria-label="breadcrumbs"
+  className="breadcrumb"
+>
+  <ul>
+    <li>
+      <a
+        href="/ui/clusters/my-cluster/connectors"
+        onClick={[Function]}
+      >
+        All Connectors
+      </a>
+    </li>
+    <li
+      className="is-active"
+    >
+      <span
+        className=""
+      >
+        New Connector
+      </span>
+    </li>
+  </ul>
+</nav>
+`;
+
+exports[`Breadcrumbs matches snapshot for root path 1`] = `
+<nav
+  aria-label="breadcrumbs"
+  className="breadcrumb"
+>
+  <ul>
+    <li
+      className="is-active"
+    >
+      <span
+        className=""
+      >
+        All Connectors
+      </span>
+    </li>
+  </ul>
+</nav>
+`;

+ 57 - 9
kafka-ui-react-app/src/components/Connect/Connect.tsx

@@ -1,16 +1,64 @@
 import React from 'react';
 import { Switch, Route } from 'react-router-dom';
-import { clusterConnectorsPath } from 'lib/paths';
-import ListContainer from 'components/Connect/List/ListContainer';
+import {
+  clusterConnectorsPath,
+  clusterConnectorNewPath,
+  clusterConnectConnectorPath,
+  clusterConnectConnectorEditPath,
+} from 'lib/paths';
+
+import Breadcrumbs from './Breadcrumbs/Breadcrumbs';
+import ListContainer from './List/ListContainer';
+import NewContainer from './New/NewContainer';
+import DetailsContainer from './Details/DetailsContainer';
+import EditContainer from './Edit/EditContainer';
 
 const Connect: React.FC = () => (
-  <Switch>
-    <Route
-      exact
-      path={clusterConnectorsPath(':clusterName')}
-      component={ListContainer}
-    />
-  </Switch>
+  <div className="section">
+    <Switch>
+      <Route
+        path={clusterConnectConnectorPath(
+          ':clusterName',
+          ':connectName',
+          ':connectorName'
+        )}
+        component={Breadcrumbs}
+      />
+      <Route
+        path={clusterConnectorsPath(':clusterName')}
+        component={Breadcrumbs}
+      />
+    </Switch>
+    <Switch>
+      <Route
+        exact
+        path={clusterConnectorsPath(':clusterName')}
+        component={ListContainer}
+      />
+      <Route
+        exact
+        path={clusterConnectorNewPath(':clusterName')}
+        component={NewContainer}
+      />
+      <Route
+        exact
+        path={clusterConnectConnectorEditPath(
+          ':clusterName',
+          ':connectName',
+          ':connectorName'
+        )}
+        component={EditContainer}
+      />
+      <Route
+        path={clusterConnectConnectorPath(
+          ':clusterName',
+          ':connectName',
+          ':connectorName'
+        )}
+        component={DetailsContainer}
+      />
+    </Switch>
+  </div>
 );
 
 export default Connect;

+ 168 - 0
kafka-ui-react-app/src/components/Connect/Details/Actions/Actions.tsx

@@ -0,0 +1,168 @@
+import React from 'react';
+import { Link, useHistory, useParams } from 'react-router-dom';
+import { ConnectorTaskStatus } from 'generated-sources';
+import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
+import {
+  clusterConnectConnectorEditPath,
+  clusterConnectorsPath,
+} from 'lib/paths';
+import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+export interface ActionsProps {
+  deleteConnector(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): Promise<void>;
+  isConnectorDeleting: boolean;
+  connectorStatus?: ConnectorTaskStatus;
+  restartConnector(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): void;
+  pauseConnector(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): void;
+  resumeConnector(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): void;
+  isConnectorActionRunning: boolean;
+}
+
+const Actions: React.FC<ActionsProps> = ({
+  deleteConnector,
+  isConnectorDeleting,
+  connectorStatus,
+  restartConnector,
+  pauseConnector,
+  resumeConnector,
+  isConnectorActionRunning,
+}) => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+  const history = useHistory();
+  const [
+    isDeleteConnectorConfirmationVisible,
+    setIsDeleteConnectorConfirmationVisible,
+  ] = React.useState(false);
+
+  const deleteConnectorHandler = React.useCallback(async () => {
+    try {
+      await deleteConnector(clusterName, connectName, connectorName);
+      history.push(clusterConnectorsPath(clusterName));
+    } catch {
+      // do not redirect
+    }
+  }, [deleteConnector, clusterName, connectName, connectorName]);
+
+  const restartConnectorHandler = React.useCallback(() => {
+    restartConnector(clusterName, connectName, connectorName);
+  }, [restartConnector, clusterName, connectName, connectorName]);
+
+  const pauseConnectorHandler = React.useCallback(() => {
+    pauseConnector(clusterName, connectName, connectorName);
+  }, [pauseConnector, clusterName, connectName, connectorName]);
+
+  const resumeConnectorHandler = React.useCallback(() => {
+    resumeConnector(clusterName, connectName, connectorName);
+  }, [resumeConnector, clusterName, connectName, connectorName]);
+
+  return (
+    <div className="buttons">
+      {connectorStatus === ConnectorTaskStatus.RUNNING && (
+        <button
+          type="button"
+          className="button"
+          onClick={pauseConnectorHandler}
+          disabled={isConnectorActionRunning}
+        >
+          <span className="icon">
+            <i className="fas fa-pause" />
+          </span>
+          <span>Pause</span>
+        </button>
+      )}
+
+      {connectorStatus === ConnectorTaskStatus.PAUSED && (
+        <button
+          type="button"
+          className="button"
+          onClick={resumeConnectorHandler}
+          disabled={isConnectorActionRunning}
+        >
+          <span className="icon">
+            <i className="fas fa-play" />
+          </span>
+          <span>Resume</span>
+        </button>
+      )}
+
+      <button
+        type="button"
+        className="button"
+        onClick={restartConnectorHandler}
+        disabled={isConnectorActionRunning}
+      >
+        <span className="icon">
+          <i className="fas fa-sync-alt" />
+        </span>
+        <span>Restart all tasks</span>
+      </button>
+
+      {isConnectorActionRunning ? (
+        <button type="button" className="button" disabled>
+          <span className="icon">
+            <i className="fas fa-edit" />
+          </span>
+          <span>Edit config</span>
+        </button>
+      ) : (
+        <Link
+          to={clusterConnectConnectorEditPath(
+            clusterName,
+            connectName,
+            connectorName
+          )}
+          className="button"
+        >
+          <span className="icon">
+            <i className="fas fa-pencil-alt" />
+          </span>
+          <span>Edit config</span>
+        </Link>
+      )}
+
+      <button
+        className="button is-danger"
+        type="button"
+        onClick={() => setIsDeleteConnectorConfirmationVisible(true)}
+        disabled={isConnectorActionRunning}
+      >
+        <span className="icon">
+          <i className="far fa-trash-alt" />
+        </span>
+        <span>Delete</span>
+      </button>
+      <ConfirmationModal
+        isOpen={isDeleteConnectorConfirmationVisible}
+        onCancel={() => setIsDeleteConnectorConfirmationVisible(false)}
+        onConfirm={deleteConnectorHandler}
+        isConfirming={isConnectorDeleting}
+      >
+        Are you sure you want to remove <b>{connectorName}</b> connector?
+      </ConfirmationModal>
+    </div>
+  );
+};
+
+export default Actions;

+ 33 - 0
kafka-ui-react-app/src/components/Connect/Details/Actions/ActionsContainer.ts

@@ -0,0 +1,33 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { RootState } from 'redux/interfaces';
+import {
+  deleteConnector,
+  restartConnector,
+  pauseConnector,
+  resumeConnector,
+} from 'redux/actions';
+import {
+  getIsConnectorDeleting,
+  getConnectorStatus,
+  getIsConnectorActionRunning,
+} from 'redux/reducers/connect/selectors';
+
+import Actions from './Actions';
+
+const mapStateToProps = (state: RootState) => ({
+  isConnectorDeleting: getIsConnectorDeleting(state),
+  connectorStatus: getConnectorStatus(state),
+  isConnectorActionRunning: getIsConnectorActionRunning(state),
+});
+
+const mapDispatchToProps = {
+  deleteConnector,
+  restartConnector,
+  pauseConnector,
+  resumeConnector,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(Actions)
+);

+ 188 - 0
kafka-ui-react-app/src/components/Connect/Details/Actions/__tests__/Actions.spec.tsx

@@ -0,0 +1,188 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import { clusterConnectConnectorPath, clusterConnectorsPath } from 'lib/paths';
+import ActionsContainer from 'components/Connect/Details/Actions/ActionsContainer';
+import Actions, {
+  ActionsProps,
+} from 'components/Connect/Details/Actions/Actions';
+import { ConnectorTaskStatus } from 'generated-sources';
+import { ConfirmationModalProps } from 'components/common/ConfirmationModal/ConfirmationModal';
+
+const mockHistoryPush = jest.fn();
+jest.mock('react-router-dom', () => ({
+  ...jest.requireActual('react-router-dom'),
+  useHistory: () => ({
+    push: mockHistoryPush,
+  }),
+}));
+
+jest.mock(
+  'components/common/ConfirmationModal/ConfirmationModal',
+  () => 'mock-ConfirmationModal'
+);
+
+describe('Actions', () => {
+  containerRendersView(<ActionsContainer />, Actions);
+
+  describe('view', () => {
+    const pathname = clusterConnectConnectorPath(
+      ':clusterName',
+      ':connectName',
+      ':connectorName'
+    );
+    const clusterName = 'my-cluster';
+    const connectName = 'my-connect';
+    const connectorName = 'my-connector';
+
+    const setupWrapper = (props: Partial<ActionsProps> = {}) => (
+      <TestRouterWrapper
+        pathname={pathname}
+        urlParams={{ clusterName, connectName, connectorName }}
+      >
+        <Actions
+          deleteConnector={jest.fn()}
+          isConnectorDeleting={false}
+          connectorStatus={ConnectorTaskStatus.RUNNING}
+          restartConnector={jest.fn()}
+          pauseConnector={jest.fn()}
+          resumeConnector={jest.fn()}
+          isConnectorActionRunning={false}
+          {...props}
+        />
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when paused', () => {
+      const wrapper = create(
+        setupWrapper({ connectorStatus: ConnectorTaskStatus.PAUSED })
+      );
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when failed', () => {
+      const wrapper = create(
+        setupWrapper({ connectorStatus: ConnectorTaskStatus.FAILED })
+      );
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when unassigned', () => {
+      const wrapper = create(
+        setupWrapper({ connectorStatus: ConnectorTaskStatus.UNASSIGNED })
+      );
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when deleting connector', () => {
+      const wrapper = create(setupWrapper({ isConnectorDeleting: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when running connector action', () => {
+      const wrapper = create(setupWrapper({ isConnectorActionRunning: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('opens confirmation modal when delete button clicked and closes when cancel button clicked', () => {
+      const deleteConnector = jest.fn();
+      const wrapper = mount(setupWrapper({ deleteConnector }));
+      wrapper.find({ children: 'Delete' }).simulate('click');
+      let confirmationModalProps = wrapper
+        .find('mock-ConfirmationModal')
+        .props() as ConfirmationModalProps;
+      expect(confirmationModalProps.isOpen).toBeTruthy();
+      act(() => {
+        confirmationModalProps.onCancel();
+      });
+      wrapper.update();
+      confirmationModalProps = wrapper
+        .find('mock-ConfirmationModal')
+        .props() as ConfirmationModalProps;
+      expect(confirmationModalProps.isOpen).toBeFalsy();
+    });
+
+    it('calls deleteConnector when confirm button clicked', () => {
+      const deleteConnector = jest.fn();
+      const wrapper = mount(setupWrapper({ deleteConnector }));
+      (wrapper
+        .find('mock-ConfirmationModal')
+        .props() as ConfirmationModalProps).onConfirm();
+      expect(deleteConnector).toHaveBeenCalledTimes(1);
+      expect(deleteConnector).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+
+    it('redirects after delete', async () => {
+      const deleteConnector = jest
+        .fn()
+        .mockResolvedValueOnce({ message: 'success' });
+      const wrapper = mount(setupWrapper({ deleteConnector }));
+      await act(async () => {
+        (wrapper
+          .find('mock-ConfirmationModal')
+          .props() as ConfirmationModalProps).onConfirm();
+      });
+      expect(mockHistoryPush).toHaveBeenCalledTimes(1);
+      expect(mockHistoryPush).toHaveBeenCalledWith(
+        clusterConnectorsPath(clusterName)
+      );
+    });
+
+    it('calls restartConnector when restart button clicked', () => {
+      const restartConnector = jest.fn();
+      const wrapper = mount(setupWrapper({ restartConnector }));
+      wrapper.find({ children: 'Restart all tasks' }).simulate('click');
+      expect(restartConnector).toHaveBeenCalledTimes(1);
+      expect(restartConnector).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+
+    it('calls pauseConnector when pause button clicked', () => {
+      const pauseConnector = jest.fn();
+      const wrapper = mount(
+        setupWrapper({
+          connectorStatus: ConnectorTaskStatus.RUNNING,
+          pauseConnector,
+        })
+      );
+      wrapper.find({ children: 'Pause' }).simulate('click');
+      expect(pauseConnector).toHaveBeenCalledTimes(1);
+      expect(pauseConnector).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+
+    it('calls resumeConnector when resume button clicked', () => {
+      const resumeConnector = jest.fn();
+      const wrapper = mount(
+        setupWrapper({
+          connectorStatus: ConnectorTaskStatus.PAUSED,
+          resumeConnector,
+        })
+      );
+      wrapper.find({ children: 'Resume' }).simulate('click');
+      expect(resumeConnector).toHaveBeenCalledTimes(1);
+      expect(resumeConnector).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+  });
+});

+ 483 - 0
kafka-ui-react-app/src/components/Connect/Details/Actions/__tests__/__snapshots__/Actions.spec.tsx.snap

@@ -0,0 +1,483 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Actions view matches snapshot 1`] = `
+<div
+  className="buttons"
+>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pause"
+      />
+    </span>
+    <span>
+      Pause
+    </span>
+  </button>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-sync-alt"
+      />
+    </span>
+    <span>
+      Restart all tasks
+    </span>
+  </button>
+  <a
+    className="button"
+    href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/edit"
+    onClick={[Function]}
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pencil-alt"
+      />
+    </span>
+    <span>
+      Edit config
+    </span>
+  </a>
+  <button
+    className="button is-danger"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="far fa-trash-alt"
+      />
+    </span>
+    <span>
+      Delete
+    </span>
+  </button>
+  <mock-ConfirmationModal
+    isConfirming={false}
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to remove 
+    <b>
+      my-connector
+    </b>
+     connector?
+  </mock-ConfirmationModal>
+</div>
+`;
+
+exports[`Actions view matches snapshot when deleting connector 1`] = `
+<div
+  className="buttons"
+>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pause"
+      />
+    </span>
+    <span>
+      Pause
+    </span>
+  </button>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-sync-alt"
+      />
+    </span>
+    <span>
+      Restart all tasks
+    </span>
+  </button>
+  <a
+    className="button"
+    href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/edit"
+    onClick={[Function]}
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pencil-alt"
+      />
+    </span>
+    <span>
+      Edit config
+    </span>
+  </a>
+  <button
+    className="button is-danger"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="far fa-trash-alt"
+      />
+    </span>
+    <span>
+      Delete
+    </span>
+  </button>
+  <mock-ConfirmationModal
+    isConfirming={true}
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to remove 
+    <b>
+      my-connector
+    </b>
+     connector?
+  </mock-ConfirmationModal>
+</div>
+`;
+
+exports[`Actions view matches snapshot when failed 1`] = `
+<div
+  className="buttons"
+>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-sync-alt"
+      />
+    </span>
+    <span>
+      Restart all tasks
+    </span>
+  </button>
+  <a
+    className="button"
+    href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/edit"
+    onClick={[Function]}
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pencil-alt"
+      />
+    </span>
+    <span>
+      Edit config
+    </span>
+  </a>
+  <button
+    className="button is-danger"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="far fa-trash-alt"
+      />
+    </span>
+    <span>
+      Delete
+    </span>
+  </button>
+  <mock-ConfirmationModal
+    isConfirming={false}
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to remove 
+    <b>
+      my-connector
+    </b>
+     connector?
+  </mock-ConfirmationModal>
+</div>
+`;
+
+exports[`Actions view matches snapshot when paused 1`] = `
+<div
+  className="buttons"
+>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-play"
+      />
+    </span>
+    <span>
+      Resume
+    </span>
+  </button>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-sync-alt"
+      />
+    </span>
+    <span>
+      Restart all tasks
+    </span>
+  </button>
+  <a
+    className="button"
+    href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/edit"
+    onClick={[Function]}
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pencil-alt"
+      />
+    </span>
+    <span>
+      Edit config
+    </span>
+  </a>
+  <button
+    className="button is-danger"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="far fa-trash-alt"
+      />
+    </span>
+    <span>
+      Delete
+    </span>
+  </button>
+  <mock-ConfirmationModal
+    isConfirming={false}
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to remove 
+    <b>
+      my-connector
+    </b>
+     connector?
+  </mock-ConfirmationModal>
+</div>
+`;
+
+exports[`Actions view matches snapshot when running connector action 1`] = `
+<div
+  className="buttons"
+>
+  <button
+    className="button"
+    disabled={true}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pause"
+      />
+    </span>
+    <span>
+      Pause
+    </span>
+  </button>
+  <button
+    className="button"
+    disabled={true}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-sync-alt"
+      />
+    </span>
+    <span>
+      Restart all tasks
+    </span>
+  </button>
+  <button
+    className="button"
+    disabled={true}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-edit"
+      />
+    </span>
+    <span>
+      Edit config
+    </span>
+  </button>
+  <button
+    className="button is-danger"
+    disabled={true}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="far fa-trash-alt"
+      />
+    </span>
+    <span>
+      Delete
+    </span>
+  </button>
+  <mock-ConfirmationModal
+    isConfirming={false}
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to remove 
+    <b>
+      my-connector
+    </b>
+     connector?
+  </mock-ConfirmationModal>
+</div>
+`;
+
+exports[`Actions view matches snapshot when unassigned 1`] = `
+<div
+  className="buttons"
+>
+  <button
+    className="button"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-sync-alt"
+      />
+    </span>
+    <span>
+      Restart all tasks
+    </span>
+  </button>
+  <a
+    className="button"
+    href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/edit"
+    onClick={[Function]}
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="fas fa-pencil-alt"
+      />
+    </span>
+    <span>
+      Edit config
+    </span>
+  </a>
+  <button
+    className="button is-danger"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    <span
+      className="icon"
+    >
+      <i
+        className="far fa-trash-alt"
+      />
+    </span>
+    <span>
+      Delete
+    </span>
+  </button>
+  <mock-ConfirmationModal
+    isConfirming={false}
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to remove 
+    <b>
+      my-connector
+    </b>
+     connector?
+  </mock-ConfirmationModal>
+</div>
+`;

+ 57 - 0
kafka-ui-react-app/src/components/Connect/Details/Config/Config.tsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import { useParams } from 'react-router';
+import {
+  ClusterName,
+  ConnectName,
+  ConnectorConfig,
+  ConnectorName,
+} from 'redux/interfaces';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import JSONEditor from 'components/common/JSONEditor/JSONEditor';
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+export interface ConfigProps {
+  fetchConfig(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName,
+    silent?: boolean
+  ): void;
+  isConfigFetching: boolean;
+  config: ConnectorConfig | null;
+}
+
+const Config: React.FC<ConfigProps> = ({
+  fetchConfig,
+  isConfigFetching,
+  config,
+}) => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+
+  React.useEffect(() => {
+    fetchConfig(clusterName, connectName, connectorName, true);
+  }, [fetchConfig, clusterName, connectName, connectorName]);
+
+  if (isConfigFetching) {
+    return <PageLoader />;
+  }
+
+  if (!config) return null;
+
+  return (
+    <JSONEditor
+      readOnly
+      value={JSON.stringify(config, null, '\t')}
+      showGutter={false}
+      highlightActiveLine={false}
+      isFixedHeight
+    />
+  );
+};
+
+export default Config;

+ 21 - 0
kafka-ui-react-app/src/components/Connect/Details/Config/ConfigContainer.ts

@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { RootState } from 'redux/interfaces';
+import { fetchConnectorConfig } from 'redux/actions';
+import {
+  getIsConnectorConfigFetching,
+  getConnectorConfig,
+} from 'redux/reducers/connect/selectors';
+
+import Config from './Config';
+
+const mapStateToProps = (state: RootState) => ({
+  isConfigFetching: getIsConnectorConfigFetching(state),
+  config: getConnectorConfig(state),
+});
+
+const mapDispatchToProps = {
+  fetchConfig: fetchConnectorConfig,
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Config));

+ 68 - 0
kafka-ui-react-app/src/components/Connect/Details/Config/__test__/Config.spec.tsx

@@ -0,0 +1,68 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { mount } from 'enzyme';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import { clusterConnectConnectorConfigPath } from 'lib/paths';
+import ConfigContainer from 'components/Connect/Details/Config/ConfigContainer';
+import Config, { ConfigProps } from 'components/Connect/Details/Config/Config';
+import { connector } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
+
+jest.mock('components/common/JSONEditor/JSONEditor', () => 'mock-JSONEditor');
+
+describe('Config', () => {
+  containerRendersView(<ConfigContainer />, Config);
+
+  describe('view', () => {
+    const pathname = clusterConnectConnectorConfigPath(
+      ':clusterName',
+      ':connectName',
+      ':connectorName'
+    );
+    const clusterName = 'my-cluster';
+    const connectName = 'my-connect';
+    const connectorName = 'my-connector';
+
+    const setupWrapper = (props: Partial<ConfigProps> = {}) => (
+      <TestRouterWrapper
+        pathname={pathname}
+        urlParams={{ clusterName, connectName, connectorName }}
+      >
+        <Config
+          fetchConfig={jest.fn()}
+          isConfigFetching={false}
+          config={connector.config}
+          {...props}
+        />
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when fetching config', () => {
+      const wrapper = create(setupWrapper({ isConfigFetching: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when no config', () => {
+      const wrapper = create(setupWrapper({ config: null }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('fetches config on mount', () => {
+      const fetchConfig = jest.fn();
+      mount(setupWrapper({ fetchConfig }));
+      expect(fetchConfig).toHaveBeenCalledTimes(1);
+      expect(fetchConfig).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName,
+        true
+      );
+    });
+  });
+});

+ 20 - 0
kafka-ui-react-app/src/components/Connect/Details/Config/__test__/__snapshots__/Config.spec.tsx.snap

@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Config view matches snapshot 1`] = `
+<mock-JSONEditor
+  highlightActiveLine={false}
+  isFixedHeight={true}
+  readOnly={true}
+  showGutter={false}
+  value="{
+	\\"connector.class\\": \\"FileStreamSource\\",
+	\\"tasks.max\\": \\"10\\",
+	\\"topic\\": \\"test-topic\\",
+	\\"file\\": \\"/some/file\\"
+}"
+/>
+`;
+
+exports[`Config view matches snapshot when fetching config 1`] = `<mock-PageLoader />`;
+
+exports[`Config view matches snapshot when no config 1`] = `null`;

+ 141 - 0
kafka-ui-react-app/src/components/Connect/Details/Details.tsx

@@ -0,0 +1,141 @@
+import React from 'react';
+import { NavLink, Route, Switch, useParams } from 'react-router-dom';
+import { Connector, Task } from 'generated-sources';
+import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
+import {
+  clusterConnectConnectorConfigPath,
+  clusterConnectConnectorPath,
+  clusterConnectConnectorTasksPath,
+} from 'lib/paths';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+
+import OverviewContainer from './Overview/OverviewContainer';
+import TasksContainer from './Tasks/TasksContainer';
+import ConfigContainer from './Config/ConfigContainer';
+import ActionsContainer from './Actions/ActionsContainer';
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+export interface DetailsProps {
+  fetchConnector(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): void;
+  fetchTasks(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): void;
+  isConnectorFetching: boolean;
+  areTasksFetching: boolean;
+  connector: Connector | null;
+  tasks: Task[];
+}
+
+const Details: React.FC<DetailsProps> = ({
+  fetchConnector,
+  fetchTasks,
+  isConnectorFetching,
+  areTasksFetching,
+  connector,
+}) => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+
+  React.useEffect(() => {
+    fetchConnector(clusterName, connectName, connectorName);
+  }, [fetchConnector, clusterName, connectName, connectorName]);
+
+  React.useEffect(() => {
+    fetchTasks(clusterName, connectName, connectorName);
+  }, [fetchTasks, clusterName, connectName, connectorName]);
+
+  if (isConnectorFetching || areTasksFetching) {
+    return <PageLoader />;
+  }
+
+  if (!connector) return null;
+
+  return (
+    <div className="box">
+      <nav className="navbar mb-4" role="navigation">
+        <div className="navbar-start tabs mb-0">
+          <NavLink
+            exact
+            to={clusterConnectConnectorPath(
+              clusterName,
+              connectName,
+              connectorName
+            )}
+            className="navbar-item is-tab"
+            activeClassName="is-active"
+          >
+            Overview
+          </NavLink>
+          <NavLink
+            exact
+            to={clusterConnectConnectorTasksPath(
+              clusterName,
+              connectName,
+              connectorName
+            )}
+            className="navbar-item is-tab"
+            activeClassName="is-active"
+          >
+            Tasks
+          </NavLink>
+          <NavLink
+            exact
+            to={clusterConnectConnectorConfigPath(
+              clusterName,
+              connectName,
+              connectorName
+            )}
+            className="navbar-item is-tab"
+            activeClassName="is-active"
+          >
+            Config
+          </NavLink>
+        </div>
+        <div className="navbar-end">
+          <ActionsContainer />
+        </div>
+      </nav>
+      <Switch>
+        <Route
+          exact
+          path={clusterConnectConnectorTasksPath(
+            ':clusterName',
+            ':connectName',
+            ':connectorName'
+          )}
+          component={TasksContainer}
+        />
+        <Route
+          exact
+          path={clusterConnectConnectorConfigPath(
+            ':clusterName',
+            ':connectName',
+            ':connectorName'
+          )}
+          component={ConfigContainer}
+        />
+        <Route
+          exact
+          path={clusterConnectConnectorPath(
+            ':clusterName',
+            ':connectName',
+            ':connectorName'
+          )}
+          component={OverviewContainer}
+        />
+      </Switch>
+    </div>
+  );
+};
+
+export default Details;

+ 28 - 0
kafka-ui-react-app/src/components/Connect/Details/DetailsContainer.ts

@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { RootState } from 'redux/interfaces';
+import { fetchConnector, fetchConnectorTasks } from 'redux/actions';
+import {
+  getIsConnectorFetching,
+  getAreConnectorTasksFetching,
+  getConnector,
+  getConnectorTasks,
+} from 'redux/reducers/connect/selectors';
+
+import Details from './Details';
+
+const mapStateToProps = (state: RootState) => ({
+  isConnectorFetching: getIsConnectorFetching(state),
+  connector: getConnector(state),
+  areTasksFetching: getAreConnectorTasksFetching(state),
+  tasks: getConnectorTasks(state),
+});
+
+const mapDispatchToProps = {
+  fetchConnector,
+  fetchTasks: fetchConnectorTasks,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(Details)
+);

+ 58 - 0
kafka-ui-react-app/src/components/Connect/Details/Overview/Overview.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+import { Connector } from 'generated-sources';
+import StatusTag from 'components/Connect/StatusTag';
+
+export interface OverviewProps {
+  connector: Connector | null;
+  runningTasksCount: number;
+  failedTasksCount: number;
+}
+
+const Overview: React.FC<OverviewProps> = ({
+  connector,
+  runningTasksCount,
+  failedTasksCount,
+}) => {
+  if (!connector) return null;
+
+  return (
+    <div className="tile is-6">
+      <table className="table is-fullwidth">
+        <tbody>
+          {connector.status?.workerId && (
+            <tr>
+              <th>Worker</th>
+              <td>{connector.status.workerId}</td>
+            </tr>
+          )}
+          <tr>
+            <th>Type</th>
+            <td>{connector.type}</td>
+          </tr>
+          {connector.config['connector.class'] && (
+            <tr>
+              <th>Class</th>
+              <td>{connector.config['connector.class']}</td>
+            </tr>
+          )}
+          <tr>
+            <th>State</th>
+            <td>
+              <StatusTag status={connector.status.state} />
+            </td>
+          </tr>
+          <tr>
+            <th>Tasks Running</th>
+            <td>{runningTasksCount}</td>
+          </tr>
+          <tr>
+            <th>Tasks Failed</th>
+            <td>{failedTasksCount}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default Overview;

+ 18 - 0
kafka-ui-react-app/src/components/Connect/Details/Overview/OverviewContainer.ts

@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { RootState } from 'redux/interfaces';
+import {
+  getConnector,
+  getConnectorRunningTasksCount,
+  getConnectorFailedTasksCount,
+} from 'redux/reducers/connect/selectors';
+
+import Overview from './Overview';
+
+const mapStateToProps = (state: RootState) => ({
+  connector: getConnector(state),
+  runningTasksCount: getConnectorRunningTasksCount(state),
+  failedTasksCount: getConnectorFailedTasksCount(state),
+});
+
+export default withRouter(connect(mapStateToProps)(Overview));

+ 35 - 0
kafka-ui-react-app/src/components/Connect/Details/Overview/__tests__/Overview.spec.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { containerRendersView } from 'lib/testHelpers';
+import OverviewContainer from 'components/Connect/Details/Overview/OverviewContainer';
+import Overview, {
+  OverviewProps,
+} from 'components/Connect/Details/Overview/Overview';
+import { connector } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/Connect/StatusTag', () => 'mock-StatusTag');
+
+describe('Overview', () => {
+  containerRendersView(<OverviewContainer />, Overview);
+
+  describe('view', () => {
+    const setupWrapper = (props: Partial<OverviewProps> = {}) => (
+      <Overview
+        connector={connector}
+        runningTasksCount={10}
+        failedTasksCount={2}
+        {...props}
+      />
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when no connector', () => {
+      const wrapper = create(setupWrapper({ connector: null }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+  });
+});

+ 66 - 0
kafka-ui-react-app/src/components/Connect/Details/Overview/__tests__/__snapshots__/Overview.spec.tsx.snap

@@ -0,0 +1,66 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Overview view matches snapshot 1`] = `
+<div
+  className="tile is-6"
+>
+  <table
+    className="table is-fullwidth"
+  >
+    <tbody>
+      <tr>
+        <th>
+          Worker
+        </th>
+        <td>
+          kafka-connect0:8083
+        </td>
+      </tr>
+      <tr>
+        <th>
+          Type
+        </th>
+        <td>
+          SOURCE
+        </td>
+      </tr>
+      <tr>
+        <th>
+          Class
+        </th>
+        <td>
+          FileStreamSource
+        </td>
+      </tr>
+      <tr>
+        <th>
+          State
+        </th>
+        <td>
+          <mock-StatusTag
+            status="RUNNING"
+          />
+        </td>
+      </tr>
+      <tr>
+        <th>
+          Tasks Running
+        </th>
+        <td>
+          10
+        </td>
+      </tr>
+      <tr>
+        <th>
+          Tasks Failed
+        </th>
+        <td>
+          2
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+`;
+
+exports[`Overview view matches snapshot when no connector 1`] = `null`;

+ 57 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/ListItem.tsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import { useParams } from 'react-router-dom';
+import { Task, TaskId } from 'generated-sources';
+import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
+import StatusTag from 'components/Connect/StatusTag';
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+export interface ListItemProps {
+  task: Task;
+  restartTask(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName,
+    taskId: TaskId['task']
+  ): Promise<void>;
+}
+
+const ListItem: React.FC<ListItemProps> = ({ task, restartTask }) => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+  const [restarting, setRestarting] = React.useState(false);
+
+  const restartTaskHandler = React.useCallback(async () => {
+    setRestarting(true);
+    await restartTask(clusterName, connectName, connectorName, task.id?.task);
+    setRestarting(false);
+  }, [restartTask, clusterName, connectName, connectorName, task.id?.task]);
+
+  return (
+    <tr>
+      <td className="has-text-overflow-ellipsis">{task.status?.id}</td>
+      <td>{task.status?.workerId}</td>
+      <td>
+        <StatusTag status={task.status.state} />
+      </td>
+      <td>{task.status.trace}</td>
+      <td>
+        <button
+          type="button"
+          className="button is-small is-pulled-right"
+          onClick={restartTaskHandler}
+          disabled={restarting}
+        >
+          <span className="icon">
+            <i className="fas fa-sync-alt" />
+          </span>
+        </button>
+      </td>
+    </tr>
+  );
+};
+
+export default ListItem;

+ 23 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/ListItemContainer.ts

@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import { Task } from 'generated-sources';
+import { RootState } from 'redux/interfaces';
+import { restartConnectorTask } from 'redux/actions';
+
+import ListItem from './ListItem';
+
+interface OwnProps extends RouteComponentProps {
+  task: Task;
+}
+
+const mapStateToProps = (state: RootState, { task }: OwnProps) => ({
+  task,
+});
+
+const mapDispatchToProps = {
+  restartTask: restartConnectorTask,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(ListItem)
+);

+ 68 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/__tests__/ListItem.spec.tsx

@@ -0,0 +1,68 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import { clusterConnectConnectorTasksPath } from 'lib/paths';
+import ListItemContainer from 'components/Connect/Details/Tasks/ListItem/ListItemContainer';
+import ListItem, {
+  ListItemProps,
+} from 'components/Connect/Details/Tasks/ListItem/ListItem';
+import { tasks } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/Connect/StatusTag', () => 'mock-StatusTag');
+
+describe('ListItem', () => {
+  containerRendersView(
+    <table>
+      <tbody>
+        <ListItemContainer task={tasks[0]} />
+      </tbody>
+    </table>,
+    ListItem
+  );
+
+  describe('view', () => {
+    const pathname = clusterConnectConnectorTasksPath(
+      ':clusterName',
+      ':connectName',
+      ':connectorName'
+    );
+    const clusterName = 'my-cluster';
+    const connectName = 'my-connect';
+    const connectorName = 'my-connector';
+
+    const setupWrapper = (props: Partial<ListItemProps> = {}) => (
+      <TestRouterWrapper
+        pathname={pathname}
+        urlParams={{ clusterName, connectName, connectorName }}
+      >
+        <table>
+          <tbody>
+            <ListItem task={tasks[0]} restartTask={jest.fn()} {...props} />
+          </tbody>
+        </table>
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('calls restartTask on button click', async () => {
+      const restartTask = jest.fn();
+      const wrapper = mount(setupWrapper({ restartTask }));
+      await act(async () => {
+        wrapper.find('button').simulate('click');
+      });
+      expect(restartTask).toHaveBeenCalledTimes(1);
+      expect(restartTask).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName,
+        tasks[0].id?.task
+      );
+    });
+  });
+});

+ 40 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/ListItem/__tests__/__snapshots__/ListItem.spec.tsx.snap

@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ListItem view matches snapshot 1`] = `
+<table>
+  <tbody>
+    <tr>
+      <td
+        className="has-text-overflow-ellipsis"
+      >
+        1
+      </td>
+      <td>
+        kafka-connect0:8083
+      </td>
+      <td>
+        <mock-StatusTag
+          status="RUNNING"
+        />
+      </td>
+      <td />
+      <td>
+        <button
+          className="button is-small is-pulled-right"
+          disabled={false}
+          onClick={[Function]}
+          type="button"
+        >
+          <span
+            className="icon"
+          >
+            <i
+              className="fas fa-sync-alt"
+            />
+          </span>
+        </button>
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 68 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/Tasks.tsx

@@ -0,0 +1,68 @@
+import React from 'react';
+import { useParams } from 'react-router';
+import { Task } from 'generated-sources';
+import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+
+import ListItemContainer from './ListItem/ListItemContainer';
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+export interface TasksProps {
+  fetchTasks(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName,
+    silent?: boolean
+  ): void;
+  areTasksFetching: boolean;
+  tasks: Task[];
+}
+
+const Tasks: React.FC<TasksProps> = ({
+  fetchTasks,
+  areTasksFetching,
+  tasks,
+}) => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+
+  React.useEffect(() => {
+    fetchTasks(clusterName, connectName, connectorName, true);
+  }, [fetchTasks, clusterName, connectName, connectorName]);
+
+  if (areTasksFetching) {
+    return <PageLoader />;
+  }
+
+  return (
+    <table className="table is-fullwidth">
+      <thead>
+        <tr>
+          <th>ID</th>
+          <th>Worker</th>
+          <th>State</th>
+          <th>Trace</th>
+          <th>
+            <span className="is-pulled-right">Restart</span>
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        {tasks.length === 0 && (
+          <tr>
+            <td colSpan={10}>No tasks found</td>
+          </tr>
+        )}
+        {tasks.map((task) => (
+          <ListItemContainer key={task.status?.id} task={task} />
+        ))}
+      </tbody>
+    </table>
+  );
+};
+
+export default Tasks;

+ 21 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/TasksContainer.ts

@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { RootState } from 'redux/interfaces';
+import { fetchConnectorTasks } from 'redux/actions';
+import {
+  getAreConnectorTasksFetching,
+  getConnectorTasks,
+} from 'redux/reducers/connect/selectors';
+
+import Tasks from './Tasks';
+
+const mapStateToProps = (state: RootState) => ({
+  areTasksFetching: getAreConnectorTasksFetching(state),
+  tasks: getConnectorTasks(state),
+});
+
+const mapDispatchToProps = {
+  fetchTasks: fetchConnectorTasks,
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Tasks));

+ 71 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/Tasks.spec.tsx

@@ -0,0 +1,71 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { mount } from 'enzyme';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import { clusterConnectConnectorTasksPath } from 'lib/paths';
+import TasksContainer from 'components/Connect/Details/Tasks/TasksContainer';
+import Tasks, { TasksProps } from 'components/Connect/Details/Tasks/Tasks';
+import { tasks } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
+
+jest.mock(
+  'components/Connect/Details/Tasks/ListItem/ListItemContainer',
+  () => 'tr' // need to mock as `tr` to let dom validtion pass
+);
+
+describe('Tasks', () => {
+  containerRendersView(<TasksContainer />, Tasks);
+
+  describe('view', () => {
+    const pathname = clusterConnectConnectorTasksPath(
+      ':clusterName',
+      ':connectName',
+      ':connectorName'
+    );
+    const clusterName = 'my-cluster';
+    const connectName = 'my-connect';
+    const connectorName = 'my-connector';
+
+    const setupWrapper = (props: Partial<TasksProps> = {}) => (
+      <TestRouterWrapper
+        pathname={pathname}
+        urlParams={{ clusterName, connectName, connectorName }}
+      >
+        <Tasks
+          fetchTasks={jest.fn()}
+          areTasksFetching={false}
+          tasks={tasks}
+          {...props}
+        />
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when fetching tasks', () => {
+      const wrapper = create(setupWrapper({ areTasksFetching: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when no tasks', () => {
+      const wrapper = create(setupWrapper({ tasks: [] }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('fetches tasks on mount', () => {
+      const fetchTasks = jest.fn();
+      mount(setupWrapper({ fetchTasks }));
+      expect(fetchTasks).toHaveBeenCalledTimes(1);
+      expect(fetchTasks).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName,
+        true
+      );
+    });
+  });
+});

+ 138 - 0
kafka-ui-react-app/src/components/Connect/Details/Tasks/__tests__/__snapshots__/Tasks.spec.tsx.snap

@@ -0,0 +1,138 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tasks view matches snapshot 1`] = `
+<table
+  className="table is-fullwidth"
+>
+  <thead>
+    <tr>
+      <th>
+        ID
+      </th>
+      <th>
+        Worker
+      </th>
+      <th>
+        State
+      </th>
+      <th>
+        Trace
+      </th>
+      <th>
+        <span
+          className="is-pulled-right"
+        >
+          Restart
+        </span>
+      </th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr
+      task={
+        Object {
+          "config": Object {
+            "batch.size": "2000",
+            "file": "/some/file",
+            "task.class": "org.apache.kafka.connect.file.FileStreamSourceTask",
+            "topic": "test-topic",
+          },
+          "id": Object {
+            "connector": "first",
+            "task": 1,
+          },
+          "status": Object {
+            "id": 1,
+            "state": "RUNNING",
+            "workerId": "kafka-connect0:8083",
+          },
+        }
+      }
+    />
+    <tr
+      task={
+        Object {
+          "config": Object {
+            "batch.size": "1000",
+            "file": "/some/file2",
+            "task.class": "org.apache.kafka.connect.file.FileStreamSourceTask",
+            "topic": "test-topic",
+          },
+          "id": Object {
+            "connector": "first",
+            "task": 2,
+          },
+          "status": Object {
+            "id": 2,
+            "state": "FAILED",
+            "trace": "Failure 1",
+            "workerId": "kafka-connect0:8083",
+          },
+        }
+      }
+    />
+    <tr
+      task={
+        Object {
+          "config": Object {
+            "batch.size": "3000",
+            "file": "/some/file3",
+            "task.class": "org.apache.kafka.connect.file.FileStreamSourceTask",
+            "topic": "test-topic",
+          },
+          "id": Object {
+            "connector": "first",
+            "task": 3,
+          },
+          "status": Object {
+            "id": 3,
+            "state": "RUNNING",
+            "workerId": "kafka-connect0:8083",
+          },
+        }
+      }
+    />
+  </tbody>
+</table>
+`;
+
+exports[`Tasks view matches snapshot when fetching tasks 1`] = `<mock-PageLoader />`;
+
+exports[`Tasks view matches snapshot when no tasks 1`] = `
+<table
+  className="table is-fullwidth"
+>
+  <thead>
+    <tr>
+      <th>
+        ID
+      </th>
+      <th>
+        Worker
+      </th>
+      <th>
+        State
+      </th>
+      <th>
+        Trace
+      </th>
+      <th>
+        <span
+          className="is-pulled-right"
+        >
+          Restart
+        </span>
+      </th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td
+        colSpan={10}
+      >
+        No tasks found
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 104 - 0
kafka-ui-react-app/src/components/Connect/Details/__tests__/Details.spec.tsx

@@ -0,0 +1,104 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { mount } from 'enzyme';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import { clusterConnectConnectorPath } from 'lib/paths';
+import DetailsContainer from 'components/Connect/Details/DetailsContainer';
+import Details, { DetailsProps } from 'components/Connect/Details/Details';
+import { connector, tasks } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
+
+jest.mock(
+  'components/Connect/Details/Overview/OverviewContainer',
+  () => 'mock-OverviewContainer'
+);
+
+jest.mock(
+  'components/Connect/Details/Tasks/TasksContainer',
+  () => 'mock-TasksContainer'
+);
+
+jest.mock(
+  'components/Connect/Details/Config/ConfigContainer',
+  () => 'mock-ConfigContainer'
+);
+
+jest.mock(
+  'components/Connect/Details/Actions/ActionsContainer',
+  () => 'mock-ActionsContainer'
+);
+
+describe('Details', () => {
+  containerRendersView(<DetailsContainer />, Details);
+
+  describe('view', () => {
+    const pathname = clusterConnectConnectorPath(
+      ':clusterName',
+      ':connectName',
+      ':connectorName'
+    );
+    const clusterName = 'my-cluster';
+    const connectName = 'my-connect';
+    const connectorName = 'my-connector';
+
+    const setupWrapper = (props: Partial<DetailsProps> = {}) => (
+      <TestRouterWrapper
+        pathname={pathname}
+        urlParams={{ clusterName, connectName, connectorName }}
+      >
+        <Details
+          fetchConnector={jest.fn()}
+          fetchTasks={jest.fn()}
+          isConnectorFetching={false}
+          areTasksFetching={false}
+          connector={connector}
+          tasks={tasks}
+          {...props}
+        />
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when fetching connector', () => {
+      const wrapper = create(setupWrapper({ isConnectorFetching: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when fetching tasks', () => {
+      const wrapper = create(setupWrapper({ areTasksFetching: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when no connector', () => {
+      const wrapper = create(setupWrapper({ connector: null }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('fetches connector on mount', () => {
+      const fetchConnector = jest.fn();
+      mount(setupWrapper({ fetchConnector }));
+      expect(fetchConnector).toHaveBeenCalledTimes(1);
+      expect(fetchConnector).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+
+    it('fetches tasks on mount', () => {
+      const fetchTasks = jest.fn();
+      mount(setupWrapper({ fetchTasks }));
+      expect(fetchTasks).toHaveBeenCalledTimes(1);
+      expect(fetchTasks).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+  });
+});

+ 105 - 0
kafka-ui-react-app/src/components/Connect/Details/__tests__/__snapshots__/Details.spec.tsx.snap

@@ -0,0 +1,105 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Details view matches snapshot 1`] = `
+<div
+  className="box"
+>
+  <nav
+    className="navbar mb-4"
+    role="navigation"
+  >
+    <div
+      className="navbar-start tabs mb-0"
+    >
+      <a
+        aria-current="page"
+        className="navbar-item is-tab is-active"
+        href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector"
+        onClick={[Function]}
+        style={Object {}}
+      >
+        Overview
+      </a>
+      <a
+        aria-current={null}
+        className="navbar-item is-tab"
+        href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/tasks"
+        onClick={[Function]}
+      >
+        Tasks
+      </a>
+      <a
+        aria-current={null}
+        className="navbar-item is-tab"
+        href="/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector/config"
+        onClick={[Function]}
+      >
+        Config
+      </a>
+    </div>
+    <div
+      className="navbar-end"
+    >
+      <mock-ActionsContainer />
+    </div>
+  </nav>
+  <mock-OverviewContainer
+    history={
+      Object {
+        "action": "POP",
+        "block": [Function],
+        "canGo": [Function],
+        "createHref": [Function],
+        "entries": Array [
+          Object {
+            "hash": "",
+            "key": "test",
+            "pathname": "/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector",
+            "search": "",
+          },
+        ],
+        "go": [Function],
+        "goBack": [Function],
+        "goForward": [Function],
+        "index": 0,
+        "length": 1,
+        "listen": [Function],
+        "location": Object {
+          "hash": "",
+          "key": "test",
+          "pathname": "/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector",
+          "search": "",
+        },
+        "push": [Function],
+        "replace": [Function],
+      }
+    }
+    location={
+      Object {
+        "hash": "",
+        "key": "test",
+        "pathname": "/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector",
+        "search": "",
+      }
+    }
+    match={
+      Object {
+        "isExact": true,
+        "params": Object {
+          "clusterName": "my-cluster",
+          "connectName": "my-connect",
+          "connectorName": "my-connector",
+        },
+        "path": "/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName",
+        "url": "/ui/clusters/my-cluster/connects/my-connect/connectors/my-connector",
+      }
+    }
+  />
+</div>
+`;
+
+exports[`Details view matches snapshot when fetching connector 1`] = `<mock-PageLoader />`;
+
+exports[`Details view matches snapshot when fetching tasks 1`] = `<mock-PageLoader />`;
+
+exports[`Details view matches snapshot when no connector 1`] = `null`;

+ 142 - 0
kafka-ui-react-app/src/components/Connect/Edit/Edit.tsx

@@ -0,0 +1,142 @@
+import React from 'react';
+import { useHistory, useParams } from 'react-router-dom';
+import { Controller, useForm } from 'react-hook-form';
+import { ErrorMessage } from '@hookform/error-message';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { Connector } from 'generated-sources';
+import {
+  ClusterName,
+  ConnectName,
+  ConnectorConfig,
+  ConnectorName,
+} from 'redux/interfaces';
+import { clusterConnectConnectorConfigPath } from 'lib/paths';
+import yup from 'lib/yupExtended';
+import JSONEditor from 'components/common/JSONEditor/JSONEditor';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+
+const validationSchema = yup.object().shape({
+  config: yup.string().required().isJsonObject(),
+});
+
+interface RouterParams {
+  clusterName: ClusterName;
+  connectName: ConnectName;
+  connectorName: ConnectorName;
+}
+
+interface FormValues {
+  config: string;
+}
+
+export interface EditProps {
+  fetchConfig(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName
+  ): Promise<void>;
+  isConfigFetching: boolean;
+  config: ConnectorConfig | null;
+  updateConfig(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    connectorName: ConnectorName,
+    connectorConfig: ConnectorConfig
+  ): Promise<Connector | undefined>;
+}
+
+const Edit: React.FC<EditProps> = ({
+  fetchConfig,
+  isConfigFetching,
+  config,
+  updateConfig,
+}) => {
+  const { clusterName, connectName, connectorName } = useParams<RouterParams>();
+  const history = useHistory();
+  const {
+    register,
+    errors,
+    handleSubmit,
+    control,
+    formState: { isDirty, isSubmitting, isValid },
+    setValue,
+  } = useForm<FormValues>({
+    mode: 'onTouched',
+    resolver: yupResolver(validationSchema),
+    defaultValues: {
+      config: JSON.stringify(config, null, '\t'),
+    },
+  });
+
+  React.useEffect(() => {
+    fetchConfig(clusterName, connectName, connectorName);
+  }, [fetchConfig, clusterName, connectName, connectorName]);
+
+  React.useEffect(() => {
+    if (config) {
+      setValue('config', JSON.stringify(config, null, '\t'));
+    }
+  }, [config, setValue]);
+
+  const onSubmit = React.useCallback(
+    async (values: FormValues) => {
+      const connector = await updateConfig(
+        clusterName,
+        connectName,
+        connectorName,
+        JSON.parse(values.config)
+      );
+      if (connector) {
+        history.push(
+          clusterConnectConnectorConfigPath(
+            clusterName,
+            connectName,
+            connectorName
+          )
+        );
+      }
+    },
+    [updateConfig, clusterName, connectName, connectorName]
+  );
+
+  if (isConfigFetching) return <PageLoader />;
+
+  return (
+    <div className="box">
+      <form onSubmit={handleSubmit(onSubmit)}>
+        <div className="field">
+          <div className="control">
+            <Controller
+              control={control}
+              name="config"
+              render={({ name, value, onChange, onBlur }) => (
+                <JSONEditor
+                  ref={register}
+                  name={name}
+                  value={value}
+                  onChange={onChange}
+                  onBlur={onBlur}
+                  readOnly={isSubmitting}
+                />
+              )}
+            />
+          </div>
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="config" />
+          </p>
+        </div>
+        <div className="field">
+          <div className="control">
+            <input
+              type="submit"
+              className="button is-primary"
+              disabled={!isValid || isSubmitting || !isDirty}
+            />
+          </div>
+        </div>
+      </form>
+    </div>
+  );
+};
+
+export default Edit;

+ 22 - 0
kafka-ui-react-app/src/components/Connect/Edit/EditContainer.ts

@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { RootState } from 'redux/interfaces';
+import { fetchConnectorConfig, updateConnectorConfig } from 'redux/actions';
+import {
+  getConnectorConfig,
+  getIsConnectorConfigFetching,
+} from 'redux/reducers/connect/selectors';
+
+import Edit from './Edit';
+
+const mapStateToProps = (state: RootState) => ({
+  isConfigFetching: getIsConnectorConfigFetching(state),
+  config: getConnectorConfig(state),
+});
+
+const mapDispatchToProps = {
+  fetchConfig: fetchConnectorConfig,
+  updateConfig: updateConnectorConfig,
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Edit));

+ 115 - 0
kafka-ui-react-app/src/components/Connect/Edit/__tests__/Edit.spec.tsx

@@ -0,0 +1,115 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import {
+  clusterConnectConnectorConfigPath,
+  clusterConnectConnectorEditPath,
+} from 'lib/paths';
+import EditContainer from 'components/Connect/Edit/EditContainer';
+import Edit, { EditProps } from 'components/Connect/Edit/Edit';
+import { connector } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
+
+jest.mock('components/common/JSONEditor/JSONEditor', () => 'mock-JSONEditor');
+
+const mockHistoryPush = jest.fn();
+jest.mock('react-router-dom', () => ({
+  ...jest.requireActual('react-router-dom'),
+  useHistory: () => ({
+    push: mockHistoryPush,
+  }),
+}));
+
+describe('Edit', () => {
+  containerRendersView(<EditContainer />, Edit);
+
+  describe('view', () => {
+    const pathname = clusterConnectConnectorEditPath(
+      ':clusterName',
+      ':connectName',
+      ':connectorName'
+    );
+    const clusterName = 'my-cluster';
+    const connectName = 'my-connect';
+    const connectorName = 'my-connector';
+
+    const setupWrapper = (props: Partial<EditProps> = {}) => (
+      <TestRouterWrapper
+        pathname={pathname}
+        urlParams={{ clusterName, connectName, connectorName }}
+      >
+        <Edit
+          fetchConfig={jest.fn()}
+          isConfigFetching={false}
+          config={connector.config}
+          updateConfig={jest.fn()}
+          {...props}
+        />
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', () => {
+      const wrapper = create(setupWrapper());
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when fetching config', () => {
+      const wrapper = create(setupWrapper({ isConfigFetching: true }));
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('fetches config on mount', () => {
+      const fetchConfig = jest.fn();
+      mount(setupWrapper({ fetchConfig }));
+      expect(fetchConfig).toHaveBeenCalledTimes(1);
+      expect(fetchConfig).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName
+      );
+    });
+
+    it('calls updateConfig on form submit', async () => {
+      const updateConfig = jest.fn();
+      const wrapper = mount(setupWrapper({ updateConfig }));
+      await act(async () => {
+        wrapper.find('form').simulate('submit');
+      });
+      expect(updateConfig).toHaveBeenCalledTimes(1);
+      expect(updateConfig).toHaveBeenCalledWith(
+        clusterName,
+        connectName,
+        connectorName,
+        connector.config
+      );
+    });
+
+    it('redirects to connector config view on successful submit', async () => {
+      const updateConfig = jest.fn().mockResolvedValueOnce(connector);
+      const wrapper = mount(setupWrapper({ updateConfig }));
+      await act(async () => {
+        wrapper.find('form').simulate('submit');
+      });
+      expect(mockHistoryPush).toHaveBeenCalledTimes(1);
+      expect(mockHistoryPush).toHaveBeenCalledWith(
+        clusterConnectConnectorConfigPath(
+          clusterName,
+          connectName,
+          connectorName
+        )
+      );
+    });
+
+    it('does not redirect to connector config view on unsuccessful submit', async () => {
+      const updateConfig = jest.fn().mockResolvedValueOnce(undefined);
+      const wrapper = mount(setupWrapper({ updateConfig }));
+      await act(async () => {
+        wrapper.find('form').simulate('submit');
+      });
+      expect(mockHistoryPush).not.toHaveBeenCalled();
+    });
+  });
+});

+ 50 - 0
kafka-ui-react-app/src/components/Connect/Edit/__tests__/__snapshots__/Edit.spec.tsx.snap

@@ -0,0 +1,50 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Edit view matches snapshot 1`] = `
+<div
+  className="box"
+>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="field"
+    >
+      <div
+        className="control"
+      >
+        <mock-JSONEditor
+          name="config"
+          onBlur={[Function]}
+          onChange={[Function]}
+          readOnly={false}
+          value="{
+	\\"connector.class\\": \\"FileStreamSource\\",
+	\\"tasks.max\\": \\"10\\",
+	\\"topic\\": \\"test-topic\\",
+	\\"file\\": \\"/some/file\\"
+}"
+        />
+      </div>
+      <p
+        className="help is-danger"
+      />
+    </div>
+    <div
+      className="field"
+    >
+      <div
+        className="control"
+      >
+        <input
+          className="button is-primary"
+          disabled={true}
+          type="submit"
+        />
+      </div>
+    </div>
+  </form>
+</div>
+`;
+
+exports[`Edit view matches snapshot when fetching config 1`] = `<mock-PageLoader />`;

+ 11 - 13
kafka-ui-react-app/src/components/Connect/List/List.tsx

@@ -1,13 +1,14 @@
 import React from 'react';
+import { Link, useParams } from 'react-router-dom';
 import { Connect, FullConnectorInfo } from 'generated-sources';
-import { useParams } from 'react-router-dom';
 import { ClusterName } from 'redux/interfaces';
-import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import { clusterConnectorNewPath } from 'lib/paths';
 import ClusterContext from 'components/contexts/ClusterContext';
 import Indicator from 'components/common/Dashboard/Indicator';
 import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
 import PageLoader from 'components/common/PageLoader/PageLoader';
-import ListItem from 'components/Connect/List/ListItem';
+
+import ListItem from './ListItem';
 
 export interface ListProps {
   areConnectsFetching: boolean;
@@ -35,13 +36,7 @@ const List: React.FC<ListProps> = ({
   }, [fetchConnects, fetchConnectors, clusterName]);
 
   return (
-    <div className="section">
-      <Breadcrumb>All Connectors</Breadcrumb>
-      <article className="message is-warning">
-        <div className="message-body">
-          Kafka Connect section is under construction.
-        </div>
-      </article>
+    <>
       <MetricsWrapper>
         <Indicator
           className="level-left is-one-third"
@@ -54,9 +49,12 @@ const List: React.FC<ListProps> = ({
 
         {!isReadOnly && (
           <div className="level-item level-right">
-            <button type="button" className="button is-primary" disabled>
+            <Link
+              className="button is-primary"
+              to={clusterConnectorNewPath(clusterName)}
+            >
               Create Connector
-            </button>
+            </Link>
           </div>
         )}
       </MetricsWrapper>
@@ -96,7 +94,7 @@ const List: React.FC<ListProps> = ({
           </table>
         </div>
       )}
-    </div>
+    </>
   );
 };
 

+ 13 - 4
kafka-ui-react-app/src/components/Connect/List/ListItem.tsx

@@ -1,9 +1,9 @@
 import React from 'react';
 import cx from 'classnames';
 import { FullConnectorInfo } from 'generated-sources';
-import { clusterTopicPath } from 'lib/paths';
+import { clusterConnectConnectorPath, clusterTopicPath } from 'lib/paths';
 import { ClusterName } from 'redux/interfaces';
-import { Link } from 'react-router-dom';
+import { Link, NavLink } from 'react-router-dom';
 import { useDispatch } from 'react-redux';
 import { deleteConnector } from 'redux/actions';
 import Dropdown from 'components/common/Dropdown/Dropdown';
@@ -50,7 +50,16 @@ const ListItem: React.FC<ListItemProps> = ({
 
   return (
     <tr>
-      <td className="has-text-overflow-ellipsis">{name}</td>
+      <td className="has-text-overflow-ellipsis">
+        <NavLink
+          exact
+          to={clusterConnectConnectorPath(clusterName, connect, name)}
+          activeClassName="is-active"
+          className="title is-6"
+        >
+          {name}
+        </NavLink>
+      </td>
       <td>{connect}</td>
       <td>{type}</td>
       <td>{connectorClass}</td>
@@ -61,7 +70,7 @@ const ListItem: React.FC<ListItemProps> = ({
           </Link>
         ))}
       </td>
-      <td>{status && <StatusTag status={status} />}</td>
+      <td>{status && <StatusTag status={status.state} />}</td>
       <td>
         {runningTasks && (
           <span

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

@@ -3,7 +3,7 @@ import { mount } from 'enzyme';
 import { Provider } from 'react-redux';
 import { StaticRouter } from 'react-router-dom';
 import configureStore from 'redux/store/configureStore';
-import { connectorsPayload } from 'redux/reducers/connect/__test__/fixtures';
+import { connectors } from 'redux/reducers/connect/__test__/fixtures';
 import ClusterContext, {
   ContextProps,
   initialValue,
@@ -66,7 +66,7 @@ describe('Connectors List', () => {
       const wrapper = mount(
         setupComponent({
           areConnectorsFetching: false,
-          connectors: connectorsPayload,
+          connectors,
         })
       );
       expect(wrapper.exists('PageLoader')).toBeFalsy();
@@ -85,7 +85,7 @@ describe('Connectors List', () => {
         setupComponent({}, { ...initialValue, isReadOnly: false })
       );
       expect(
-        wrapper.exists('.level-item.level-right > button.is-primary')
+        wrapper.exists('.level-item.level-right > .button.is-primary')
       ).toBeTruthy();
     });
 
@@ -95,7 +95,7 @@ describe('Connectors List', () => {
           setupComponent({}, { ...initialValue, isReadOnly: true })
         );
         expect(
-          wrapper.exists('.level-item.level-right > button.is-primary')
+          wrapper.exists('.level-item.level-right > .button.is-primary')
         ).toBeFalsy();
       });
     });

+ 28 - 3
kafka-ui-react-app/src/components/Connect/List/__tests__/ListItem.spec.tsx

@@ -2,19 +2,26 @@ import React from 'react';
 import { mount } from 'enzyme';
 import { Provider } from 'react-redux';
 import { BrowserRouter } from 'react-router-dom';
-import { connectorsPayload } from 'redux/reducers/connect/__test__/fixtures';
+import { connectors } from 'redux/reducers/connect/__test__/fixtures';
 import configureStore from 'redux/store/configureStore';
 import ListItem, { ListItemProps } from 'components/Connect/List/ListItem';
+import { ConfirmationModalProps } from 'components/common/ConfirmationModal/ConfirmationModal';
 
 const store = configureStore();
 
+const mockDeleteConnector = jest.fn();
+jest.mock('redux/actions', () => ({
+  ...jest.requireActual('redux/actions'),
+  deleteConnector: () => mockDeleteConnector(),
+}));
+
 jest.mock(
   'components/common/ConfirmationModal/ConfirmationModal',
   () => 'mock-ConfirmationModal'
 );
 
 describe('Connectors ListItem', () => {
-  const connector = connectorsPayload[0];
+  const connector = connectors[0];
   const setupWrapper = (props: Partial<ListItemProps> = {}) => (
     <Provider store={store}>
       <BrowserRouter>
@@ -60,7 +67,7 @@ describe('Connectors ListItem', () => {
     expect(wrapper.find('td').at(6).text()).toEqual('');
   });
 
-  it('handles delete', () => {
+  it('handles cancel', () => {
     const wrapper = mount(setupWrapper());
     expect(wrapper.find('mock-ConfirmationModal').prop('isOpen')).toBeFalsy();
     wrapper.find('DropdownItem').last().simulate('click');
@@ -70,6 +77,24 @@ describe('Connectors ListItem', () => {
     expect(wrapper.find('mock-ConfirmationModal').prop('isOpen')).toBeFalsy();
   });
 
+  it('handles delete', () => {
+    const wrapper = mount(setupWrapper());
+    const modalProps = wrapper
+      .find('mock-ConfirmationModal')
+      .props() as ConfirmationModalProps;
+    modalProps.onConfirm();
+    expect(mockDeleteConnector).toHaveBeenCalledTimes(1);
+  });
+
+  it('handles delete when clusterName is not present', () => {
+    const wrapper = mount(setupWrapper({ clusterName: undefined }));
+    const modalProps = wrapper
+      .find('mock-ConfirmationModal')
+      .props() as ConfirmationModalProps;
+    modalProps.onConfirm();
+    expect(mockDeleteConnector).toHaveBeenCalledTimes(0);
+  });
+
   it('matches snapshot', () => {
     const wrapper = mount(setupWrapper());
     expect(wrapper).toMatchSnapshot();

+ 38 - 2
kafka-ui-react-app/src/components/Connect/List/__tests__/__snapshots__/ListItem.spec.tsx.snap

@@ -45,7 +45,9 @@ exports[`Connectors ListItem matches snapshot 1`] = `
                 "connectorClass": "FileStreamSource",
                 "failedTasksCount": 0,
                 "name": "hdfs-source-connector",
-                "status": "RUNNING",
+                "status": Object {
+                  "state": "RUNNING",
+                },
                 "tasksCount": 2,
                 "topics": Array [
                   "test-topic",
@@ -58,7 +60,41 @@ exports[`Connectors ListItem matches snapshot 1`] = `
               <td
                 className="has-text-overflow-ellipsis"
               >
-                hdfs-source-connector
+                <NavLink
+                  activeClassName="is-active"
+                  className="title is-6"
+                  exact={true}
+                  to="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
+                >
+                  <Link
+                    aria-current={null}
+                    className="title is-6"
+                    to={
+                      Object {
+                        "hash": "",
+                        "pathname": "/ui/clusters/local/connects/first/connectors/hdfs-source-connector",
+                        "search": "",
+                        "state": null,
+                      }
+                    }
+                  >
+                    <LinkAnchor
+                      aria-current={null}
+                      className="title is-6"
+                      href="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
+                      navigate={[Function]}
+                    >
+                      <a
+                        aria-current={null}
+                        className="title is-6"
+                        href="/ui/clusters/local/connects/first/connectors/hdfs-source-connector"
+                        onClick={[Function]}
+                      >
+                        hdfs-source-connector
+                      </a>
+                    </LinkAnchor>
+                  </Link>
+                </NavLink>
               </td>
               <td>
                 first

+ 179 - 0
kafka-ui-react-app/src/components/Connect/New/New.tsx

@@ -0,0 +1,179 @@
+import React from 'react';
+import { useHistory, useParams } from 'react-router-dom';
+import { Controller, useForm } from 'react-hook-form';
+import { ErrorMessage } from '@hookform/error-message';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { Connect, Connector, NewConnector } from 'generated-sources';
+import { ClusterName, ConnectName } from 'redux/interfaces';
+import { clusterConnectConnectorPath } from 'lib/paths';
+import yup from 'lib/yupExtended';
+import JSONEditor from 'components/common/JSONEditor/JSONEditor';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+
+const validationSchema = yup.object().shape({
+  name: yup.string().required(),
+  config: yup.string().required().isJsonObject(),
+});
+
+interface RouterParams {
+  clusterName: ClusterName;
+}
+
+export interface NewProps {
+  fetchConnects(clusterName: ClusterName): void;
+  areConnectsFetching: boolean;
+  connects: Connect[];
+  createConnector(
+    clusterName: ClusterName,
+    connectName: ConnectName,
+    newConnector: NewConnector
+  ): Promise<Connector | undefined>;
+}
+
+interface FormValues {
+  connectName: ConnectName;
+  name: string;
+  config: string;
+}
+
+const New: React.FC<NewProps> = ({
+  fetchConnects,
+  areConnectsFetching,
+  connects,
+  createConnector,
+}) => {
+  const { clusterName } = useParams<RouterParams>();
+  const history = useHistory();
+
+  const {
+    register,
+    errors,
+    handleSubmit,
+    control,
+    formState: { isDirty, isSubmitting, isValid },
+    getValues,
+    setValue,
+  } = useForm<FormValues>({
+    mode: 'onTouched',
+    resolver: yupResolver(validationSchema),
+    defaultValues: {
+      connectName: connects[0]?.name || '',
+      name: '',
+      config: '',
+    },
+  });
+
+  React.useEffect(() => {
+    fetchConnects(clusterName);
+  }, [fetchConnects, clusterName]);
+
+  React.useEffect(() => {
+    if (connects && connects.length > 0 && !getValues().connectName) {
+      setValue('connectName', connects[0].name);
+    }
+  }, [connects, getValues, setValue]);
+
+  const connectNameFieldClassName = React.useMemo(
+    () => (connects.length > 1 ? '' : 'is-hidden'),
+    [connects]
+  );
+
+  const onSubmit = React.useCallback(
+    async (values: FormValues) => {
+      const connector = await createConnector(clusterName, values.connectName, {
+        name: values.name,
+        config: JSON.parse(values.config),
+      });
+      if (connector) {
+        history.push(
+          clusterConnectConnectorPath(
+            clusterName,
+            connector.connect,
+            connector.name
+          )
+        );
+      }
+    },
+    [createConnector, clusterName]
+  );
+
+  if (areConnectsFetching) {
+    return <PageLoader />;
+  }
+
+  if (connects.length === 0) {
+    return null;
+  }
+
+  return (
+    <div className="box">
+      <form onSubmit={handleSubmit(onSubmit)}>
+        <div className={['field', connectNameFieldClassName].join(' ')}>
+          <label className="label">Connect *</label>
+          <div className="control select">
+            <select ref={register} name="connectName" disabled={isSubmitting}>
+              {connects.map(({ name }) => (
+                <option key={name} value={name}>
+                  {name}
+                </option>
+              ))}
+            </select>
+          </div>
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="connectName" />
+          </p>
+        </div>
+
+        <div className="field">
+          <label className="label">Name *</label>
+          <div className="control">
+            <input
+              ref={register}
+              className="input"
+              placeholder="Connector Name"
+              name="name"
+              autoComplete="off"
+              disabled={isSubmitting}
+            />
+          </div>
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="name" />
+          </p>
+        </div>
+
+        <div className="field">
+          <label className="label">Config *</label>
+          <div className="control">
+            <Controller
+              control={control}
+              name="config"
+              render={({ name, onChange, onBlur }) => (
+                <JSONEditor
+                  ref={register}
+                  name={name}
+                  onChange={onChange}
+                  onBlur={onBlur}
+                  readOnly={isSubmitting}
+                />
+              )}
+            />
+          </div>
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="config" />
+          </p>
+        </div>
+        <div className="field">
+          <div className="control">
+            <input
+              type="submit"
+              className="button is-primary"
+              disabled={!isValid || isSubmitting || !isDirty}
+            />
+          </div>
+        </div>
+      </form>
+    </div>
+  );
+};
+
+export default New;

+ 22 - 0
kafka-ui-react-app/src/components/Connect/New/NewContainer.ts

@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { createConnector, fetchConnects } from 'redux/actions';
+import { RootState } from 'redux/interfaces';
+import {
+  getAreConnectsFetching,
+  getConnects,
+} from 'redux/reducers/connect/selectors';
+
+import New from './New';
+
+const mapStateToProps = (state: RootState) => ({
+  areConnectsFetching: getAreConnectsFetching(state),
+  connects: getConnects(state),
+});
+
+const mapDispatchToProps = {
+  fetchConnects,
+  createConnector,
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(New));

+ 117 - 0
kafka-ui-react-app/src/components/Connect/New/__tests__/New.spec.tsx

@@ -0,0 +1,117 @@
+import React from 'react';
+import { create, act as rendererAct } from 'react-test-renderer';
+import { mount, ReactWrapper } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
+import {
+  clusterConnectConnectorPath,
+  clusterConnectorNewPath,
+} from 'lib/paths';
+import NewContainer from 'components/Connect/New/NewContainer';
+import New, { NewProps } from 'components/Connect/New/New';
+import { connects, connector } from 'redux/reducers/connect/__test__/fixtures';
+
+jest.mock('components/common/PageLoader/PageLoader', () => 'mock-PageLoader');
+
+jest.mock('components/common/JSONEditor/JSONEditor', () => 'mock-JSONEditor');
+
+const mockHistoryPush = jest.fn();
+jest.mock('react-router-dom', () => ({
+  ...jest.requireActual('react-router-dom'),
+  useHistory: () => ({
+    push: mockHistoryPush,
+  }),
+}));
+
+describe('New', () => {
+  containerRendersView(<NewContainer />, New);
+
+  describe('view', () => {
+    const pathname = clusterConnectorNewPath(':clusterName');
+    const clusterName = 'my-cluster';
+    const simulateFormSubmit = (wrapper: ReactWrapper) =>
+      act(async () => {
+        const nameInput = wrapper
+          .find('input[name="name"]')
+          .getDOMNode<HTMLInputElement>();
+        nameInput.value = 'my-connector';
+        wrapper
+          .find('mock-JSONEditor')
+          .simulate('change', { target: { value: '{"class":"MyClass"}' } });
+        wrapper.find('input[type="submit"]').simulate('submit');
+      });
+
+    const setupWrapper = (props: Partial<NewProps> = {}) => (
+      <TestRouterWrapper pathname={pathname} urlParams={{ clusterName }}>
+        <New
+          fetchConnects={jest.fn()}
+          areConnectsFetching={false}
+          connects={connects}
+          createConnector={jest.fn()}
+          {...props}
+        />
+      </TestRouterWrapper>
+    );
+
+    it('matches snapshot', async () => {
+      let wrapper = create(<div />);
+      await rendererAct(async () => {
+        wrapper = create(setupWrapper());
+      });
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('matches snapshot when fetching connects', async () => {
+      let wrapper = create(<div />);
+      await rendererAct(async () => {
+        wrapper = create(setupWrapper({ areConnectsFetching: true }));
+      });
+      expect(wrapper.toJSON()).toMatchSnapshot();
+    });
+
+    it('fetches connects on mount', async () => {
+      const fetchConnects = jest.fn();
+      await act(async () => {
+        mount(setupWrapper({ fetchConnects }));
+      });
+      expect(fetchConnects).toHaveBeenCalledTimes(1);
+      expect(fetchConnects).toHaveBeenCalledWith(clusterName);
+    });
+
+    it('calls createConnector on form submit', async () => {
+      const createConnector = jest.fn();
+      const wrapper = mount(setupWrapper({ createConnector }));
+      await simulateFormSubmit(wrapper);
+      expect(createConnector).toHaveBeenCalledTimes(1);
+      expect(createConnector).toHaveBeenCalledWith(
+        clusterName,
+        connects[0].name,
+        {
+          name: 'my-connector',
+          config: { class: 'MyClass' },
+        }
+      );
+    });
+
+    it('redirects to connector details view on successful submit', async () => {
+      const createConnector = jest.fn().mockResolvedValue(connector);
+      const wrapper = mount(setupWrapper({ createConnector }));
+      await simulateFormSubmit(wrapper);
+      expect(mockHistoryPush).toHaveBeenCalledTimes(1);
+      expect(mockHistoryPush).toHaveBeenCalledWith(
+        clusterConnectConnectorPath(
+          clusterName,
+          connects[0].name,
+          connector.name
+        )
+      );
+    });
+
+    it('does not redirect to connector details view on unsuccessful submit', async () => {
+      const createConnector = jest.fn().mockResolvedValueOnce(undefined);
+      const wrapper = mount(setupWrapper({ createConnector }));
+      await simulateFormSubmit(wrapper);
+      expect(mockHistoryPush).not.toHaveBeenCalled();
+    });
+  });
+});

+ 103 - 0
kafka-ui-react-app/src/components/Connect/New/__tests__/__snapshots__/New.spec.tsx.snap

@@ -0,0 +1,103 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`New view matches snapshot 1`] = `
+<div
+  className="box"
+>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="field "
+    >
+      <label
+        className="label"
+      >
+        Connect *
+      </label>
+      <div
+        className="control select"
+      >
+        <select
+          disabled={false}
+          name="connectName"
+        >
+          <option
+            value="first"
+          >
+            first
+          </option>
+          <option
+            value="second"
+          >
+            second
+          </option>
+        </select>
+      </div>
+      <p
+        className="help is-danger"
+      />
+    </div>
+    <div
+      className="field"
+    >
+      <label
+        className="label"
+      >
+        Name *
+      </label>
+      <div
+        className="control"
+      >
+        <input
+          autoComplete="off"
+          className="input"
+          disabled={false}
+          name="name"
+          placeholder="Connector Name"
+        />
+      </div>
+      <p
+        className="help is-danger"
+      />
+    </div>
+    <div
+      className="field"
+    >
+      <label
+        className="label"
+      >
+        Config *
+      </label>
+      <div
+        className="control"
+      >
+        <mock-JSONEditor
+          name="config"
+          onBlur={[Function]}
+          onChange={[Function]}
+          readOnly={false}
+        />
+      </div>
+      <p
+        className="help is-danger"
+      />
+    </div>
+    <div
+      className="field"
+    >
+      <div
+        className="control"
+      >
+        <input
+          className="button is-primary"
+          disabled={true}
+          type="submit"
+        />
+      </div>
+    </div>
+  </form>
+</div>
+`;
+
+exports[`New view matches snapshot when fetching connects 1`] = `<mock-PageLoader />`;

+ 2 - 2
kafka-ui-react-app/src/components/Connect/StatusTag.tsx

@@ -9,8 +9,8 @@ export interface StatusTagProps {
 const StatusTag: React.FC<StatusTagProps> = ({ status }) => {
   const classNames = cx('tag', {
     'is-success': status === ConnectorTaskStatus.RUNNING,
-    'is-success is-light': status === ConnectorTaskStatus.PAUSED,
-    'is-light': status === ConnectorTaskStatus.UNASSIGNED,
+    'is-light': status === ConnectorTaskStatus.PAUSED,
+    'is-warning': status === ConnectorTaskStatus.UNASSIGNED,
     'is-danger': status === ConnectorTaskStatus.FAILED,
   });
 

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

@@ -0,0 +1,10 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import Connect from 'components/Connect/Connect';
+
+describe('Connect', () => {
+  it('matches snapshot', () => {
+    const wrapper = shallow(<Connect />);
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 38 - 0
kafka-ui-react-app/src/components/Connect/__tests__/StatusTag.spec.tsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import { create } from 'react-test-renderer';
+import StatusTag, { StatusTagProps } from 'components/Connect/StatusTag';
+import { ConnectorTaskStatus } from 'generated-sources';
+
+describe('StatusTag', () => {
+  const setupWrapper = (props: Partial<StatusTagProps> = {}) => (
+    <StatusTag status={ConnectorTaskStatus.RUNNING} {...props} />
+  );
+
+  it('matches snapshot for running status', () => {
+    const wrapper = create(
+      setupWrapper({ status: ConnectorTaskStatus.RUNNING })
+    );
+    expect(wrapper.toJSON()).toMatchSnapshot();
+  });
+
+  it('matches snapshot for failed status', () => {
+    const wrapper = create(
+      setupWrapper({ status: ConnectorTaskStatus.FAILED })
+    );
+    expect(wrapper.toJSON()).toMatchSnapshot();
+  });
+
+  it('matches snapshot for paused status', () => {
+    const wrapper = create(
+      setupWrapper({ status: ConnectorTaskStatus.PAUSED })
+    );
+    expect(wrapper.toJSON()).toMatchSnapshot();
+  });
+
+  it('matches snapshot for unassigned status', () => {
+    const wrapper = create(
+      setupWrapper({ status: ConnectorTaskStatus.UNASSIGNED })
+    );
+    expect(wrapper.toJSON()).toMatchSnapshot();
+  });
+});

+ 46 - 0
kafka-ui-react-app/src/components/Connect/__tests__/__snapshots__/Connect.spec.tsx.snap

@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Connect matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <Switch>
+    <Route
+      component={[Function]}
+      path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName"
+    />
+    <Route
+      component={[Function]}
+      path="/ui/clusters/:clusterName/connectors"
+    />
+  </Switch>
+  <Switch>
+    <Route
+      component={
+        Object {
+          "$$typeof": Symbol(react.memo),
+          "WrappedComponent": [Function],
+          "compare": null,
+          "type": [Function],
+        }
+      }
+      exact={true}
+      path="/ui/clusters/:clusterName/connectors"
+    />
+    <Route
+      component={[Function]}
+      exact={true}
+      path="/ui/clusters/:clusterName/connectors/create_new"
+    />
+    <Route
+      component={[Function]}
+      exact={true}
+      path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName/edit"
+    />
+    <Route
+      component={[Function]}
+      path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName"
+    />
+  </Switch>
+</div>
+`;

+ 33 - 0
kafka-ui-react-app/src/components/Connect/__tests__/__snapshots__/StatusTag.spec.tsx.snap

@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`StatusTag matches snapshot for failed status 1`] = `
+<span
+  className="tag is-danger"
+>
+  FAILED
+</span>
+`;
+
+exports[`StatusTag matches snapshot for paused status 1`] = `
+<span
+  className="tag is-light"
+>
+  PAUSED
+</span>
+`;
+
+exports[`StatusTag matches snapshot for running status 1`] = `
+<span
+  className="tag is-success"
+>
+  RUNNING
+</span>
+`;
+
+exports[`StatusTag matches snapshot for unassigned status 1`] = `
+<span
+  className="tag is-warning"
+>
+  UNASSIGNED
+</span>
+`;

+ 5 - 0
kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx

@@ -7,6 +7,7 @@ import {
   clusterConsumerGroupsPath,
   clusterSchemasPath,
   clusterConnectorsPath,
+  clusterConnectsPath,
 } from 'lib/paths';
 
 import DefaultClusterIcon from './DefaultClusterIcon';
@@ -82,6 +83,10 @@ const ClusterMenu: React.FC<Props> = ({
                 to={clusterConnectorsPath(name)}
                 activeClassName="is-active"
                 title="Kafka Connect"
+                isActive={(_, location) =>
+                  location.pathname.startsWith(clusterConnectsPath(name)) ||
+                  location.pathname.startsWith(clusterConnectorsPath(name))
+                }
               >
                 Kafka Connect
               </NavLink>

+ 22 - 3
kafka-ui-react-app/src/components/common/ConfirmationModal/ConfirmationModal.tsx

@@ -5,6 +5,7 @@ export interface ConfirmationModalProps {
   title?: React.ReactNode;
   onConfirm(): void;
   onCancel(): void;
+  isConfirming?: boolean;
 }
 
 const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
@@ -13,20 +14,32 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
   title,
   onCancel,
   onConfirm,
+  isConfirming = false,
 }) => {
   if (!isOpen) return null;
 
+  const cancelHandler = React.useCallback(() => {
+    if (!isConfirming) {
+      onCancel();
+    }
+  }, [isConfirming, onCancel]);
+
   return (
     <div className="modal is-active">
-      <div className="modal-background" onClick={onCancel} aria-hidden="true" />
+      <div
+        className="modal-background"
+        onClick={cancelHandler}
+        aria-hidden="true"
+      />
       <div className="modal-card">
         <header className="modal-card-head">
           <p className="modal-card-title">{title || 'Confirm the action'}</p>
           <button
-            onClick={onCancel}
+            onClick={cancelHandler}
             type="button"
             className="delete"
             aria-label="close"
+            disabled={isConfirming}
           />
         </header>
         <section className="modal-card-body">{children}</section>
@@ -35,10 +48,16 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
             onClick={onConfirm}
             type="button"
             className="button is-danger"
+            disabled={isConfirming}
           >
             Confirm
           </button>
-          <button onClick={onCancel} type="button" className="button">
+          <button
+            onClick={cancelHandler}
+            type="button"
+            className="button"
+            disabled={isConfirming}
+          >
             Cancel
           </button>
         </footer>

+ 35 - 18
kafka-ui-react-app/src/components/common/ConfirmationModal/__test__/ConfirmationModal.spec.tsx

@@ -24,6 +24,7 @@ describe('ConfiramationModal', () => {
     expect(wrapper.exists(ConfirmationModal)).toBeTruthy();
     expect(wrapper.exists('.modal.is-active')).toBeTruthy();
     expect(wrapper.find('.modal-card-body').text()).toEqual(body);
+    expect(wrapper.find('.modal-card-foot button').length).toEqual(2);
   });
   it('renders modal with default header', () => {
     const wrapper = mount(setupWrapper({ isOpen: true }));
@@ -38,31 +39,47 @@ describe('ConfiramationModal', () => {
   });
   it('handles onConfirm when user clicks confirm button', () => {
     const wrapper = mount(setupWrapper({ isOpen: true }));
-    expect(wrapper.find('.modal-card-foot button').length).toEqual(2);
-    const cancelBtn = wrapper.find('.modal-card-foot button').at(0);
-    expect(cancelBtn.text()).toEqual('Confirm');
-    cancelBtn.simulate('click');
+    const confirmBtn = wrapper.find({ children: 'Confirm' });
+    confirmBtn.simulate('click');
     expect(cancelMock).toHaveBeenCalledTimes(0);
     expect(confirmMock).toHaveBeenCalledTimes(1);
   });
 
   describe('cancellation', () => {
     let wrapper: ReactWrapper;
-    beforeEach(() => {
-      wrapper = mount(setupWrapper({ isOpen: true }));
-    });
-    it('handles onCancel when user clicks on modal-background', () => {
-      wrapper.find('.modal-background').simulate('click');
-      expect(cancelMock).toHaveBeenCalledTimes(1);
-      expect(confirmMock).toHaveBeenCalledTimes(0);
+
+    describe('when not confirming', () => {
+      beforeEach(() => {
+        wrapper = mount(setupWrapper({ isOpen: true }));
+      });
+      it('handles onCancel when user clicks on modal-background', () => {
+        wrapper.find('.modal-background').simulate('click');
+        expect(cancelMock).toHaveBeenCalledTimes(1);
+        expect(confirmMock).toHaveBeenCalledTimes(0);
+      });
+      it('handles onCancel when user clicks on Cancel button', () => {
+        const cancelBtn = wrapper.find({ children: 'Cancel' });
+        cancelBtn.simulate('click');
+        expect(cancelMock).toHaveBeenCalledTimes(1);
+        expect(confirmMock).toHaveBeenCalledTimes(0);
+      });
     });
-    it('handles onCancel when user clicks on Cancel button', () => {
-      expect(wrapper.find('.modal-card-foot button').length).toEqual(2);
-      const cancelBtn = wrapper.find('.modal-card-foot button').at(1);
-      expect(cancelBtn.text()).toEqual('Cancel');
-      cancelBtn.simulate('click');
-      expect(cancelMock).toHaveBeenCalledTimes(1);
-      expect(confirmMock).toHaveBeenCalledTimes(0);
+
+    describe('when confirming', () => {
+      beforeEach(() => {
+        wrapper = mount(setupWrapper({ isOpen: true, isConfirming: true }));
+      });
+      it('does not call onCancel when user clicks on modal-background', () => {
+        wrapper.find('.modal-background').simulate('click');
+        expect(cancelMock).toHaveBeenCalledTimes(0);
+        expect(confirmMock).toHaveBeenCalledTimes(0);
+      });
+      it('does not call onCancel when user clicks on Cancel button', () => {
+        const cancelBtn = wrapper.find({ children: 'Cancel' });
+        cancelBtn.simulate('click');
+        expect(cancelMock).toHaveBeenCalledTimes(0);
+        expect(confirmMock).toHaveBeenCalledTimes(0);
+      });
     });
   });
 });

+ 24 - 16
kafka-ui-react-app/src/components/common/JSONEditor/JSONEditor.tsx

@@ -3,26 +3,34 @@ import AceEditor, { IAceEditorProps } from 'react-ace';
 import 'ace-builds/src-noconflict/mode-json5';
 import 'ace-builds/src-noconflict/theme-textmate';
 import React from 'react';
+import ReactAce from 'react-ace/lib/ace';
 
 interface JSONEditorProps extends IAceEditorProps {
   isFixedHeight?: boolean;
 }
 
-const JSONEditor: React.FC<JSONEditorProps> = (props) => {
-  const { isFixedHeight, value } = props;
-  return (
-    <AceEditor
-      mode="json5"
-      theme="textmate"
-      tabSize={2}
-      width="100%"
-      height={
-        isFixedHeight ? `${(value?.split('\n').length || 32) * 16}px` : '500px'
-      }
-      wrapEnabled
-      {...props}
-    />
-  );
-};
+const JSONEditor = React.forwardRef<ReactAce | null, JSONEditorProps>(
+  (props, ref) => {
+    const { isFixedHeight, ...rest } = props;
+    return (
+      <AceEditor
+        ref={ref}
+        mode="json5"
+        theme="textmate"
+        tabSize={2}
+        width="100%"
+        height={
+          isFixedHeight
+            ? `${(props.value?.split('\n').length || 32) * 16}px`
+            : '500px'
+        }
+        wrapEnabled
+        {...rest}
+      />
+    );
+  }
+);
+
+JSONEditor.displayName = 'JSONEditor';
 
 export default JSONEditor;

+ 12 - 0
kafka-ui-react-app/src/components/common/JSONEditor/__tests__/JSONEditor.spec.tsx

@@ -7,4 +7,16 @@ describe('JSONEditor component', () => {
     const component = shallow(<JSONEditor value="{}" name="name" />);
     expect(component).toMatchSnapshot();
   });
+
+  it('matches the snapshot with fixed height', () => {
+    const component = shallow(
+      <JSONEditor value="{}" name="name" isFixedHeight />
+    );
+    expect(component).toMatchSnapshot();
+  });
+
+  it('matches the snapshot with fixed height with no value', () => {
+    const component = shallow(<JSONEditor name="name" isFixedHeight />);
+    expect(component).toMatchSnapshot();
+  });
 });

+ 83 - 0
kafka-ui-react-app/src/components/common/JSONEditor/__tests__/__snapshots__/JSONEditor.spec.tsx.snap

@@ -41,3 +41,86 @@ exports[`JSONEditor component matches the snapshot 1`] = `
   wrapEnabled={true}
 />
 `;
+
+exports[`JSONEditor component matches the snapshot with fixed height 1`] = `
+<ReactAce
+  cursorStart={1}
+  editorProps={Object {}}
+  enableBasicAutocompletion={false}
+  enableLiveAutocompletion={false}
+  enableSnippets={false}
+  focus={false}
+  fontSize={12}
+  height="16px"
+  highlightActiveLine={true}
+  maxLines={null}
+  minLines={null}
+  mode="json5"
+  name="name"
+  navigateToFileEnd={true}
+  onChange={null}
+  onLoad={null}
+  onPaste={null}
+  onScroll={null}
+  placeholder={null}
+  readOnly={false}
+  scrollMargin={
+    Array [
+      0,
+      0,
+      0,
+      0,
+    ]
+  }
+  setOptions={Object {}}
+  showGutter={true}
+  showPrintMargin={true}
+  style={Object {}}
+  tabSize={2}
+  theme="textmate"
+  value="{}"
+  width="100%"
+  wrapEnabled={true}
+/>
+`;
+
+exports[`JSONEditor component matches the snapshot with fixed height with no value 1`] = `
+<ReactAce
+  cursorStart={1}
+  editorProps={Object {}}
+  enableBasicAutocompletion={false}
+  enableLiveAutocompletion={false}
+  enableSnippets={false}
+  focus={false}
+  fontSize={12}
+  height="512px"
+  highlightActiveLine={true}
+  maxLines={null}
+  minLines={null}
+  mode="json5"
+  name="name"
+  navigateToFileEnd={true}
+  onChange={null}
+  onLoad={null}
+  onPaste={null}
+  onScroll={null}
+  placeholder={null}
+  readOnly={false}
+  scrollMargin={
+    Array [
+      0,
+      0,
+      0,
+      0,
+    ]
+  }
+  setOptions={Object {}}
+  showGutter={true}
+  showPrintMargin={true}
+  style={Object {}}
+  tabSize={2}
+  theme="textmate"
+  width="100%"
+  wrapEnabled={true}
+/>
+`;

+ 24 - 0
kafka-ui-react-app/src/lib/__test__/yupExtended.spec.ts

@@ -0,0 +1,24 @@
+import { isValidJsonObject } from 'lib/yupExtended';
+
+describe('yup extended', () => {
+  describe('isValidJsonObject', () => {
+    it('returns false for no value', () => {
+      expect(isValidJsonObject()).toBeFalsy();
+    });
+
+    it('returns false for invalid string', () => {
+      expect(isValidJsonObject('foo: bar')).toBeFalsy();
+    });
+
+    it('returns false on parsing error', () => {
+      JSON.parse = jest.fn().mockImplementationOnce(() => {
+        throw new Error();
+      });
+      expect(isValidJsonObject('{ "foo": "bar" }')).toBeFalsy();
+    });
+
+    it('returns true for valid JSON object', () => {
+      expect(isValidJsonObject('{ "foo": "bar" }')).toBeTruthy();
+    });
+  });
+});

+ 50 - 1
kafka-ui-react-app/src/lib/paths.ts

@@ -1,4 +1,10 @@
-import { ClusterName, SchemaName, TopicName } from 'redux/interfaces';
+import {
+  ClusterName,
+  ConnectName,
+  ConnectorName,
+  SchemaName,
+  TopicName,
+} from 'redux/interfaces';
 
 import { GIT_REPO_LINK } from './constants';
 
@@ -52,5 +58,48 @@ export const clusterTopicEditPath = (
 ) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;
 
 // Kafka Connect
+export const clusterConnectsPath = (clusterName: ClusterName) =>
+  `${clusterPath(clusterName)}/connects`;
 export const clusterConnectorsPath = (clusterName: ClusterName) =>
   `${clusterPath(clusterName)}/connectors`;
+export const clusterConnectorNewPath = (clusterName: ClusterName) =>
+  `${clusterConnectorsPath(clusterName)}/create_new`;
+const clusterConnectConnectorsPath = (
+  clusterName: ClusterName,
+  connectName: ConnectName
+) => `${clusterConnectsPath(clusterName)}/${connectName}/connectors`;
+export const clusterConnectConnectorPath = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+) =>
+  `${clusterConnectConnectorsPath(clusterName, connectName)}/${connectorName}`;
+export const clusterConnectConnectorEditPath = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+) =>
+  `${clusterConnectConnectorsPath(
+    clusterName,
+    connectName
+  )}/${connectorName}/edit`;
+export const clusterConnectConnectorTasksPath = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+) =>
+  `${clusterConnectConnectorPath(
+    clusterName,
+    connectName,
+    connectorName
+  )}/tasks`;
+export const clusterConnectConnectorConfigPath = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+) =>
+  `${clusterConnectConnectorPath(
+    clusterName,
+    connectName,
+    connectorName
+  )}/config`;

+ 55 - 0
kafka-ui-react-app/src/lib/testHelpers.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+import { MemoryRouter, Route, StaticRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import configureStore from 'redux/store/configureStore';
+
+interface TestRouterWrapperProps {
+  pathname: string;
+  urlParams: {
+    [key: string]: string;
+  };
+}
+
+export const TestRouterWrapper: React.FC<TestRouterWrapperProps> = ({
+  children,
+  pathname,
+  urlParams,
+}) => (
+  <MemoryRouter
+    initialEntries={[
+      {
+        key: 'test',
+        pathname: Object.keys(urlParams).reduce(
+          (acc, param) => acc.replace(`:${param}`, urlParams[param]),
+          pathname
+        ),
+      },
+    ]}
+  >
+    <Route path={pathname}>{children}</Route>
+  </MemoryRouter>
+);
+
+export const containerRendersView = (
+  container: React.ReactElement,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  view: React.FC<any>
+) => {
+  describe('container', () => {
+    const store = configureStore();
+
+    it('renders view', async () => {
+      let wrapper = mount(<div />);
+      await act(async () => {
+        wrapper = mount(
+          <Provider store={store}>
+            <StaticRouter>{container}</StaticRouter>
+          </Provider>
+        );
+      });
+      expect(wrapper.exists(view)).toBeTruthy();
+    });
+  });
+};

+ 41 - 0
kafka-ui-react-app/src/lib/yupExtended.ts

@@ -0,0 +1,41 @@
+import * as yup from 'yup';
+import { AnyObject, Maybe } from 'yup/lib/types';
+
+declare module 'yup' {
+  interface StringSchema<
+    TType extends Maybe<string> = string | undefined,
+    TContext extends AnyObject = AnyObject,
+    TOut extends TType = TType
+  > extends yup.BaseSchema<TType, TContext, TOut> {
+    isJsonObject(): StringSchema<TType, TContext>;
+  }
+}
+
+export const isValidJsonObject = (value?: string) => {
+  try {
+    if (!value) return false;
+    if (
+      value.indexOf('{') === 0 &&
+      value.lastIndexOf('}') === value.length - 1
+    ) {
+      JSON.parse(value);
+      return true;
+    }
+  } catch {
+    // do nothing
+  }
+  return false;
+};
+
+const isJsonObject = () => {
+  return yup.string().test(
+    'isJsonObject',
+    // eslint-disable-next-line no-template-curly-in-string
+    '${path} is not JSON object',
+    isValidJsonObject
+  );
+};
+
+yup.addMethod(yup.string, 'isJsonObject', isJsonObject);
+
+export default yup;

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

@@ -1,15 +1,23 @@
 import fetchMock from 'fetch-mock-jest';
+import { ConnectorAction } from 'generated-sources';
 import * as actions from 'redux/actions/actions';
 import * as thunks from 'redux/actions/thunks';
 import {
-  connectorsPayload,
+  connects,
   connectorsServerPayload,
-  connectsPayload,
+  connectors,
+  connectorServerPayload,
+  connector,
+  tasksServerPayload,
+  tasks,
 } from 'redux/reducers/connect/__test__/fixtures';
 import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
 
 const store = mockStoreCreator;
 const clusterName = 'local';
+const connectName = 'first';
+const connectorName = 'hdfs-source-connector';
+const taskId = 10;
 
 describe('Thunks', () => {
   afterEach(() => {
@@ -19,17 +27,11 @@ describe('Thunks', () => {
 
   describe('fetchConnects', () => {
     it('creates GET_CONNECTS__SUCCESS when fetching connects', async () => {
-      fetchMock.getOnce(
-        `/api/clusters/${clusterName}/connects`,
-        connectsPayload
-      );
+      fetchMock.getOnce(`/api/clusters/${clusterName}/connects`, connects);
       await store.dispatch(thunks.fetchConnects(clusterName));
       expect(store.getActions()).toEqual([
         actions.fetchConnectsAction.request(),
-        actions.fetchConnectsAction.success({
-          ...store.getState().connect,
-          connects: connectsPayload,
-        }),
+        actions.fetchConnectsAction.success({ connects }),
       ]);
     });
 
@@ -41,7 +43,7 @@ describe('Thunks', () => {
         actions.fetchConnectsAction.failure({
           alert: {
             subject: 'connects',
-            title: `Kafka Connect`,
+            title: 'Kafka Connect',
             response: {
               status: 404,
               statusText: 'Not Found',
@@ -54,7 +56,7 @@ describe('Thunks', () => {
   });
 
   describe('fetchConnectors', () => {
-    it('creates GET_CONNECTORS__SUCCESS when fetching connects', async () => {
+    it('creates GET_CONNECTORS__SUCCESS when fetching connectors', async () => {
       fetchMock.getOnce(
         `/api/clusters/${clusterName}/connectors`,
         connectorsServerPayload
@@ -62,14 +64,11 @@ describe('Thunks', () => {
       await store.dispatch(thunks.fetchConnectors(clusterName));
       expect(store.getActions()).toEqual([
         actions.fetchConnectorsAction.request(),
-        actions.fetchConnectorsAction.success({
-          ...store.getState().connect,
-          connectors: connectorsPayload,
-        }),
+        actions.fetchConnectorsAction.success({ connectors }),
       ]);
     });
 
-    it('creates GET_CONNECTORS__SUCCESS when fetching connects in silent mode', async () => {
+    it('creates GET_CONNECTORS__SUCCESS when fetching connectors in silent mode', async () => {
       fetchMock.getOnce(
         `/api/clusters/${clusterName}/connectors`,
         connectorsServerPayload
@@ -78,7 +77,7 @@ describe('Thunks', () => {
       expect(store.getActions()).toEqual([
         actions.fetchConnectorsAction.success({
           ...store.getState().connect,
-          connectors: connectorsPayload,
+          connectors,
         }),
       ]);
     });
@@ -103,10 +102,105 @@ describe('Thunks', () => {
     });
   });
 
-  describe('deleteConnector', () => {
-    const connectName = 'first';
-    const connectorName = 'hdfs-source-connector';
+  describe('fetchConnector', () => {
+    it('creates GET_CONNECTOR__SUCCESS when fetching connects', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}`,
+        connectorServerPayload
+      );
+      await store.dispatch(
+        thunks.fetchConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.fetchConnectorAction.request(),
+        actions.fetchConnectorAction.success({ connector }),
+      ]);
+    });
+
+    it('creates GET_CONNECTOR__FAILURE', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}`,
+        404
+      );
+      await store.dispatch(
+        thunks.fetchConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.fetchConnectorAction.request(),
+        actions.fetchConnectorAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector',
+            title: 'Kafka Connect Connector',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
 
+  describe('createConnector', () => {
+    it('creates POST_CONNECTOR__SUCCESS when fetching connects', async () => {
+      fetchMock.postOnce(
+        {
+          url: `/api/clusters/${clusterName}/connects/${connectName}/connectors`,
+          body: {
+            name: connectorName,
+            config: connector.config,
+          },
+        },
+        connectorServerPayload
+      );
+      await store.dispatch(
+        thunks.createConnector(clusterName, connectName, {
+          name: connectorName,
+          config: connector.config,
+        })
+      );
+      expect(store.getActions()).toEqual([
+        actions.createConnectorAction.request(),
+        actions.createConnectorAction.success({ connector }),
+      ]);
+    });
+
+    it('creates POST_CONNECTOR__FAILURE', async () => {
+      fetchMock.postOnce(
+        {
+          url: `/api/clusters/${clusterName}/connects/${connectName}/connectors`,
+          body: {
+            name: connectorName,
+            config: connector.config,
+          },
+        },
+        404
+      );
+      await store.dispatch(
+        thunks.createConnector(clusterName, connectName, {
+          name: connectorName,
+          config: connector.config,
+        })
+      );
+      expect(store.getActions()).toEqual([
+        actions.createConnectorAction.request(),
+        actions.createConnectorAction.failure({
+          alert: {
+            subject: 'local-first',
+            title: 'Kafka Connect Connector Create',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('deleteConnector', () => {
     it('creates DELETE_CONNECTOR__SUCCESS', async () => {
       fetchMock.deleteOnce(
         `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}`,
@@ -121,9 +215,7 @@ describe('Thunks', () => {
       );
       expect(store.getActions()).toEqual([
         actions.deleteConnectorAction.request(),
-        actions.deleteConnectorAction.success({
-          ...store.getState().connect,
-        }),
+        actions.deleteConnectorAction.success({ connectorName }),
       ]);
     });
 
@@ -132,15 +224,328 @@ describe('Thunks', () => {
         `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}`,
         404
       );
+      try {
+        await store.dispatch(
+          thunks.deleteConnector(clusterName, connectName, connectorName)
+        );
+      } catch {
+        expect(store.getActions()).toEqual([
+          actions.deleteConnectorAction.request(),
+          actions.deleteConnectorAction.failure({
+            alert: {
+              subject: 'local-first-hdfs-source-connector',
+              title: 'Kafka Connect Connector Delete',
+              response: {
+                status: 404,
+                statusText: 'Not Found',
+                body: undefined,
+              },
+            },
+          }),
+        ]);
+      }
+    });
+  });
+
+  describe('fetchConnectorTasks', () => {
+    it('creates GET_CONNECTOR_TASKS__SUCCESS when fetching connects', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/tasks`,
+        tasksServerPayload
+      );
       await store.dispatch(
-        thunks.deleteConnector(clusterName, connectName, connectorName)
+        thunks.fetchConnectorTasks(clusterName, connectName, connectorName)
       );
       expect(store.getActions()).toEqual([
-        actions.deleteConnectorAction.request(),
-        actions.deleteConnectorAction.failure({
+        actions.fetchConnectorTasksAction.request(),
+        actions.fetchConnectorTasksAction.success({ tasks }),
+      ]);
+    });
+
+    it('creates GET_CONNECTOR_TASKS__FAILURE', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/tasks`,
+        404
+      );
+      await store.dispatch(
+        thunks.fetchConnectorTasks(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.fetchConnectorTasksAction.request(),
+        actions.fetchConnectorTasksAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector',
+            title: 'Kafka Connect Connector Tasks',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('restartConnector', () => {
+    it('creates RESTART_CONNECTOR__SUCCESS when fetching connects', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/action/${ConnectorAction.RESTART}`,
+        { message: 'success' }
+      );
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/tasks`,
+        tasksServerPayload
+      );
+      await store.dispatch(
+        thunks.restartConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.restartConnectorAction.request(),
+        actions.restartConnectorAction.success(),
+      ]);
+    });
+
+    it('creates RESTART_CONNECTOR__FAILURE', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/action/${ConnectorAction.RESTART}`,
+        404
+      );
+      await store.dispatch(
+        thunks.restartConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.restartConnectorAction.request(),
+        actions.restartConnectorAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector',
+            title: 'Kafka Connect Connector Tasks Restart',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('pauseConnector', () => {
+    it('creates PAUSE_CONNECTOR__SUCCESS when fetching connects', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/action/${ConnectorAction.PAUSE}`,
+        { message: 'success' }
+      );
+      await store.dispatch(
+        thunks.pauseConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.pauseConnectorAction.request(),
+        actions.pauseConnectorAction.success({ connectorName }),
+      ]);
+    });
+
+    it('creates PAUSE_CONNECTOR__FAILURE', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/action/${ConnectorAction.PAUSE}`,
+        404
+      );
+      await store.dispatch(
+        thunks.pauseConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.pauseConnectorAction.request(),
+        actions.pauseConnectorAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector',
+            title: 'Kafka Connect Connector Pause',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('resumeConnector', () => {
+    it('creates RESUME_CONNECTOR__SUCCESS when fetching connects', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/action/${ConnectorAction.RESUME}`,
+        { message: 'success' }
+      );
+      await store.dispatch(
+        thunks.resumeConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.resumeConnectorAction.request(),
+        actions.resumeConnectorAction.success({ connectorName }),
+      ]);
+    });
+
+    it('creates RESUME_CONNECTOR__FAILURE', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/action/${ConnectorAction.RESUME}`,
+        404
+      );
+      await store.dispatch(
+        thunks.resumeConnector(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.resumeConnectorAction.request(),
+        actions.resumeConnectorAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector',
+            title: 'Kafka Connect Connector Resume',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('restartConnectorTask', () => {
+    it('creates RESTART_CONNECTOR_TASK__SUCCESS when fetching connects', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/tasks/${taskId}/action/restart`,
+        { message: 'success' }
+      );
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/tasks`,
+        tasksServerPayload
+      );
+      await store.dispatch(
+        thunks.restartConnectorTask(
+          clusterName,
+          connectName,
+          connectorName,
+          taskId
+        )
+      );
+      expect(store.getActions()).toEqual([
+        actions.restartConnectorTaskAction.request(),
+        actions.restartConnectorTaskAction.success(),
+      ]);
+    });
+
+    it('creates RESTART_CONNECTOR_TASK__FAILURE', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/tasks/${taskId}/action/restart`,
+        404
+      );
+      await store.dispatch(
+        thunks.restartConnectorTask(
+          clusterName,
+          connectName,
+          connectorName,
+          taskId
+        )
+      );
+      expect(store.getActions()).toEqual([
+        actions.restartConnectorTaskAction.request(),
+        actions.restartConnectorTaskAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector-10',
+            title: 'Kafka Connect Connector Task Restart',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('fetchConnectorConfig', () => {
+    it('creates GET_CONNECTOR_CONFIG__SUCCESS when fetching connects', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/config`,
+        connector.config
+      );
+      await store.dispatch(
+        thunks.fetchConnectorConfig(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.fetchConnectorConfigAction.request(),
+        actions.fetchConnectorConfigAction.success({
+          config: connector.config,
+        }),
+      ]);
+    });
+
+    it('creates GET_CONNECTOR_CONFIG__FAILURE', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/config`,
+        404
+      );
+      await store.dispatch(
+        thunks.fetchConnectorConfig(clusterName, connectName, connectorName)
+      );
+      expect(store.getActions()).toEqual([
+        actions.fetchConnectorConfigAction.request(),
+        actions.fetchConnectorConfigAction.failure({
+          alert: {
+            subject: 'local-first-hdfs-source-connector',
+            title: 'Kafka Connect Connector Config',
+            response: {
+              status: 404,
+              statusText: 'Not Found',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+
+  describe('updateConnectorConfig', () => {
+    it('creates PATCH_CONNECTOR_CONFIG__SUCCESS when fetching connects', async () => {
+      fetchMock.putOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/config`,
+        connectorServerPayload
+      );
+      await store.dispatch(
+        thunks.updateConnectorConfig(
+          clusterName,
+          connectName,
+          connectorName,
+          connector.config
+        )
+      );
+      expect(store.getActions()).toEqual([
+        actions.updateConnectorConfigAction.request(),
+        actions.updateConnectorConfigAction.success({ connector }),
+      ]);
+    });
+
+    it('creates PATCH_CONNECTOR_CONFIG__FAILURE', async () => {
+      fetchMock.putOnce(
+        `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/config`,
+        404
+      );
+      await store.dispatch(
+        thunks.updateConnectorConfig(
+          clusterName,
+          connectName,
+          connectorName,
+          connector.config
+        )
+      );
+      expect(store.getActions()).toEqual([
+        actions.updateConnectorConfigAction.request(),
+        actions.updateConnectorConfigAction.failure({
           alert: {
             subject: 'local-first-hdfs-source-connector',
-            title: 'Kafka Connect Connector Delete',
+            title: 'Kafka Connect Connector Config Update',
             response: {
               status: 404,
               statusText: 'Not Found',

+ 58 - 5
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -4,7 +4,8 @@ import {
   FailurePayload,
   TopicName,
   TopicsState,
-  ConnectState,
+  ConnectorName,
+  ConnectorConfig,
 } from 'redux/interfaces';
 import {
   Cluster,
@@ -17,6 +18,10 @@ import {
   ConsumerGroupDetails,
   SchemaSubject,
   CompatibilityLevelCompatibilityEnum,
+  Connector,
+  FullConnectorInfo,
+  Connect,
+  Task,
 } from 'generated-sources';
 
 export const fetchClusterStatsAction = createAsyncAction(
@@ -161,22 +166,70 @@ export const fetchConnectsAction = createAsyncAction(
   'GET_CONNECTS__REQUEST',
   'GET_CONNECTS__SUCCESS',
   'GET_CONNECTS__FAILURE'
-)<undefined, ConnectState, { alert?: FailurePayload }>();
+)<undefined, { connects: Connect[] }, { alert?: FailurePayload }>();
 
 export const fetchConnectorsAction = createAsyncAction(
   'GET_CONNECTORS__REQUEST',
   'GET_CONNECTORS__SUCCESS',
   'GET_CONNECTORS__FAILURE'
-)<undefined, ConnectState, { alert?: FailurePayload }>();
+)<undefined, { connectors: FullConnectorInfo[] }, { alert?: FailurePayload }>();
 
 export const fetchConnectorAction = createAsyncAction(
   'GET_CONNECTOR__REQUEST',
   'GET_CONNECTOR__SUCCESS',
   'GET_CONNECTOR__FAILURE'
-)<undefined, ConnectState, { alert?: FailurePayload }>();
+)<undefined, { connector: Connector }, { alert?: FailurePayload }>();
+
+export const createConnectorAction = createAsyncAction(
+  'POST_CONNECTOR__REQUEST',
+  'POST_CONNECTOR__SUCCESS',
+  'POST_CONNECTOR__FAILURE'
+)<undefined, { connector: Connector }, { alert?: FailurePayload }>();
 
 export const deleteConnectorAction = createAsyncAction(
   'DELETE_CONNECTOR__REQUEST',
   'DELETE_CONNECTOR__SUCCESS',
   'DELETE_CONNECTOR__FAILURE'
-)<undefined, ConnectState, { alert?: FailurePayload }>();
+)<undefined, { connectorName: ConnectorName }, { alert?: FailurePayload }>();
+
+export const restartConnectorAction = createAsyncAction(
+  'RESTART_CONNECTOR__REQUEST',
+  'RESTART_CONNECTOR__SUCCESS',
+  'RESTART_CONNECTOR__FAILURE'
+)<undefined, undefined, { alert?: FailurePayload }>();
+
+export const pauseConnectorAction = createAsyncAction(
+  'PAUSE_CONNECTOR__REQUEST',
+  'PAUSE_CONNECTOR__SUCCESS',
+  'PAUSE_CONNECTOR__FAILURE'
+)<undefined, { connectorName: ConnectorName }, { alert?: FailurePayload }>();
+
+export const resumeConnectorAction = createAsyncAction(
+  'RESUME_CONNECTOR__REQUEST',
+  'RESUME_CONNECTOR__SUCCESS',
+  'RESUME_CONNECTOR__FAILURE'
+)<undefined, { connectorName: ConnectorName }, { alert?: FailurePayload }>();
+
+export const fetchConnectorTasksAction = createAsyncAction(
+  'GET_CONNECTOR_TASKS__REQUEST',
+  'GET_CONNECTOR_TASKS__SUCCESS',
+  'GET_CONNECTOR_TASKS__FAILURE'
+)<undefined, { tasks: Task[] }, { alert?: FailurePayload }>();
+
+export const restartConnectorTaskAction = createAsyncAction(
+  'RESTART_CONNECTOR_TASK__REQUEST',
+  'RESTART_CONNECTOR_TASK__SUCCESS',
+  'RESTART_CONNECTOR_TASK__FAILURE'
+)<undefined, undefined, { alert?: FailurePayload }>();
+
+export const fetchConnectorConfigAction = createAsyncAction(
+  'GET_CONNECTOR_CONFIG__REQUEST',
+  'GET_CONNECTOR_CONFIG__SUCCESS',
+  'GET_CONNECTOR_CONFIG__FAILURE'
+)<undefined, { config: ConnectorConfig }, { alert?: FailurePayload }>();
+
+export const updateConnectorConfigAction = createAsyncAction(
+  'PATCH_CONNECTOR_CONFIG__REQUEST',
+  'PATCH_CONNECTOR_CONFIG__SUCCESS',
+  'PATCH_CONNECTOR_CONFIG__FAILURE'
+)<undefined, { connector: Connector }, { alert?: FailurePayload }>();

+ 255 - 19
kafka-ui-react-app/src/redux/actions/thunks/connectors.ts

@@ -1,7 +1,17 @@
-import { KafkaConnectApi, Configuration } from 'generated-sources';
+import {
+  KafkaConnectApi,
+  Configuration,
+  NewConnector,
+  Connector,
+  ConnectorAction,
+  TaskId,
+} from 'generated-sources';
 import { BASE_PARAMS } from 'lib/constants';
 import {
   ClusterName,
+  ConnectName,
+  ConnectorConfig,
+  ConnectorName,
   FailurePayload,
   PromiseThunkResult,
 } from 'redux/interfaces';
@@ -13,12 +23,11 @@ export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
 
 export const fetchConnects = (
   clusterName: ClusterName
-): PromiseThunkResult<void> => async (dispatch, getState) => {
+): PromiseThunkResult<void> => async (dispatch) => {
   dispatch(actions.fetchConnectsAction.request());
   try {
     const connects = await kafkaConnectApiClient.getConnects({ clusterName });
-    const state = getState().connect;
-    dispatch(actions.fetchConnectsAction.success({ ...state, connects }));
+    dispatch(actions.fetchConnectsAction.success({ connects }));
   } catch (error) {
     const response = await getResponse(error);
     const alert: FailurePayload = {
@@ -33,14 +42,13 @@ export const fetchConnects = (
 export const fetchConnectors = (
   clusterName: ClusterName,
   silent = false
-): PromiseThunkResult<void> => async (dispatch, getState) => {
+): PromiseThunkResult<void> => async (dispatch) => {
   if (!silent) dispatch(actions.fetchConnectorsAction.request());
   try {
     const connectors = await kafkaConnectApiClient.getAllConnectors({
       clusterName,
     });
-    const state = getState().connect;
-    dispatch(actions.fetchConnectorsAction.success({ ...state, connectors }));
+    dispatch(actions.fetchConnectorsAction.success({ connectors }));
   } catch (error) {
     const response = await getResponse(error);
     const alert: FailurePayload = {
@@ -52,11 +60,61 @@ export const fetchConnectors = (
   }
 };
 
+export const fetchConnector = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.fetchConnectorAction.request());
+  try {
+    const connector = await kafkaConnectApiClient.getConnector({
+      clusterName,
+      connectName,
+      connectorName,
+    });
+    dispatch(actions.fetchConnectorAction.success({ connector }));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector`,
+      response,
+    };
+    dispatch(actions.fetchConnectorAction.failure({ alert }));
+  }
+};
+
+export const createConnector = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  newConnector: NewConnector
+): PromiseThunkResult<Connector | undefined> => async (dispatch) => {
+  dispatch(actions.createConnectorAction.request());
+  try {
+    const connector = await kafkaConnectApiClient.createConnector({
+      clusterName,
+      connectName,
+      newConnector,
+    });
+    dispatch(actions.createConnectorAction.success({ connector }));
+    return connector;
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName].join('-'),
+      title: `Kafka Connect Connector Create`,
+      response,
+    };
+    dispatch(actions.createConnectorAction.failure({ alert }));
+  }
+  return undefined;
+};
+
 export const deleteConnector = (
   clusterName: ClusterName,
-  connectName: string,
-  connectorName: string
-): PromiseThunkResult<void> => async (dispatch, getState) => {
+  connectName: ConnectName,
+  connectorName: ConnectorName
+): PromiseThunkResult<void> => async (dispatch) => {
   dispatch(actions.deleteConnectorAction.request());
   try {
     await kafkaConnectApiClient.deleteConnector({
@@ -64,15 +122,7 @@ export const deleteConnector = (
       connectName,
       connectorName,
     });
-    const state = getState().connect;
-    dispatch(
-      actions.deleteConnectorAction.success({
-        ...state,
-        connectors: state?.connectors.filter(
-          ({ name }) => name !== connectorName
-        ),
-      })
-    );
+    dispatch(actions.deleteConnectorAction.success({ connectorName }));
     dispatch(fetchConnectors(clusterName, true));
   } catch (error) {
     const response = await getResponse(error);
@@ -82,5 +132,191 @@ export const deleteConnector = (
       response,
     };
     dispatch(actions.deleteConnectorAction.failure({ alert }));
+    throw error;
+  }
+};
+
+export const fetchConnectorTasks = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName,
+  silent = false
+): PromiseThunkResult<void> => async (dispatch) => {
+  if (!silent) dispatch(actions.fetchConnectorTasksAction.request());
+  try {
+    const tasks = await kafkaConnectApiClient.getConnectorTasks({
+      clusterName,
+      connectName,
+      connectorName,
+    });
+    dispatch(actions.fetchConnectorTasksAction.success({ tasks }));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector Tasks`,
+      response,
+    };
+    dispatch(actions.fetchConnectorTasksAction.failure({ alert }));
+  }
+};
+
+export const restartConnector = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.restartConnectorAction.request());
+  try {
+    await kafkaConnectApiClient.updateConnectorState({
+      clusterName,
+      connectName,
+      connectorName,
+      action: ConnectorAction.RESTART,
+    });
+    dispatch(actions.restartConnectorAction.success());
+    dispatch(
+      fetchConnectorTasks(clusterName, connectName, connectorName, true)
+    );
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector Tasks Restart`,
+      response,
+    };
+    dispatch(actions.restartConnectorAction.failure({ alert }));
+  }
+};
+
+export const pauseConnector = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.pauseConnectorAction.request());
+  try {
+    await kafkaConnectApiClient.updateConnectorState({
+      clusterName,
+      connectName,
+      connectorName,
+      action: ConnectorAction.PAUSE,
+    });
+    dispatch(actions.pauseConnectorAction.success({ connectorName }));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector Pause`,
+      response,
+    };
+    dispatch(actions.pauseConnectorAction.failure({ alert }));
+  }
+};
+
+export const resumeConnector = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.resumeConnectorAction.request());
+  try {
+    await kafkaConnectApiClient.updateConnectorState({
+      clusterName,
+      connectName,
+      connectorName,
+      action: ConnectorAction.RESUME,
+    });
+    dispatch(actions.resumeConnectorAction.success({ connectorName }));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector Resume`,
+      response,
+    };
+    dispatch(actions.resumeConnectorAction.failure({ alert }));
+  }
+};
+
+export const restartConnectorTask = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName,
+  taskId: TaskId['task']
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.restartConnectorTaskAction.request());
+  try {
+    await kafkaConnectApiClient.restartConnectorTask({
+      clusterName,
+      connectName,
+      connectorName,
+      taskId: Number(taskId),
+    });
+    dispatch(actions.restartConnectorTaskAction.success());
+    dispatch(
+      fetchConnectorTasks(clusterName, connectName, connectorName, true)
+    );
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName, taskId].join('-'),
+      title: `Kafka Connect Connector Task Restart`,
+      response,
+    };
+    dispatch(actions.restartConnectorTaskAction.failure({ alert }));
+  }
+};
+
+export const fetchConnectorConfig = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName,
+  silent = false
+): PromiseThunkResult<void> => async (dispatch) => {
+  if (!silent) dispatch(actions.fetchConnectorConfigAction.request());
+  try {
+    const config = await kafkaConnectApiClient.getConnectorConfig({
+      clusterName,
+      connectName,
+      connectorName,
+    });
+    dispatch(actions.fetchConnectorConfigAction.success({ config }));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector Config`,
+      response,
+    };
+    dispatch(actions.fetchConnectorConfigAction.failure({ alert }));
+  }
+};
+
+export const updateConnectorConfig = (
+  clusterName: ClusterName,
+  connectName: ConnectName,
+  connectorName: ConnectorName,
+  connectorConfig: ConnectorConfig
+): PromiseThunkResult<Connector | undefined> => async (dispatch) => {
+  dispatch(actions.updateConnectorConfigAction.request());
+  try {
+    const connector = await kafkaConnectApiClient.setConnectorConfig({
+      clusterName,
+      connectName,
+      connectorName,
+      requestBody: connectorConfig,
+    });
+    dispatch(actions.updateConnectorConfigAction.success({ connector }));
+    return connector;
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: [clusterName, connectName, connectorName].join('-'),
+      title: `Kafka Connect Connector Config Update`,
+      response,
+    };
+    dispatch(actions.updateConnectorConfigAction.failure({ alert }));
   }
+  return undefined;
 };

+ 12 - 1
kafka-ui-react-app/src/redux/interfaces/connect.ts

@@ -1,6 +1,17 @@
-import { Connect, FullConnectorInfo } from 'generated-sources';
+import { Connect, Connector, FullConnectorInfo, Task } from 'generated-sources';
+
+export type ConnectName = Connect['name'];
+export type ConnectorName = Connector['name'];
+export interface ConnectorConfig {
+  [key: string]: string | undefined;
+}
 
 export interface ConnectState {
   connects: Connect[];
   connectors: FullConnectorInfo[];
+  currentConnector: {
+    connector: Connector | null;
+    tasks: Task[];
+    config: ConnectorConfig | null;
+  };
 }

+ 145 - 6
kafka-ui-react-app/src/redux/reducers/connect/__test__/fixtures.ts

@@ -1,10 +1,13 @@
 import {
+  Connect,
+  Connector,
   ConnectorTaskStatus,
   ConnectorType,
   FullConnectorInfo,
+  Task,
 } from 'generated-sources';
 
-export const connectsPayload = [
+export const connects: Connect[] = [
   { name: 'first', address: 'localhost:8083' },
   { name: 'second', address: 'localhost:8084' },
 ];
@@ -16,7 +19,10 @@ export const connectorsServerPayload = [
     connector_class: 'FileStreamSource',
     type: ConnectorType.SOURCE,
     topics: ['test-topic'],
-    status: ConnectorTaskStatus.RUNNING,
+    status: {
+      state: ConnectorTaskStatus.RUNNING,
+      workerId: 1,
+    },
     tasks_count: 2,
     failed_tasks_count: 0,
   },
@@ -26,20 +32,25 @@ export const connectorsServerPayload = [
     connector_class: 'FileStreamSource',
     type: ConnectorType.SINK,
     topics: ['test-topic'],
-    status: ConnectorTaskStatus.FAILED,
+    status: {
+      state: ConnectorTaskStatus.FAILED,
+      workerId: 1,
+    },
     tasks_count: 3,
     failed_tasks_count: 1,
   },
 ];
 
-export const connectorsPayload: FullConnectorInfo[] = [
+export const connectors: FullConnectorInfo[] = [
   {
     connect: 'first',
     name: 'hdfs-source-connector',
     connectorClass: 'FileStreamSource',
     type: ConnectorType.SOURCE,
     topics: ['test-topic'],
-    status: ConnectorTaskStatus.RUNNING,
+    status: {
+      state: ConnectorTaskStatus.RUNNING,
+    },
     tasksCount: 2,
     failedTasksCount: 0,
   },
@@ -49,8 +60,136 @@ export const connectorsPayload: FullConnectorInfo[] = [
     connectorClass: 'FileStreamSource',
     type: ConnectorType.SINK,
     topics: ['test-topic'],
-    status: ConnectorTaskStatus.FAILED,
+    status: {
+      state: ConnectorTaskStatus.FAILED,
+    },
     tasksCount: 3,
     failedTasksCount: 1,
   },
 ];
+
+export const connectorServerPayload = {
+  connect: 'first',
+  name: 'hdfs-source-connector',
+  type: ConnectorType.SOURCE,
+  status: {
+    state: ConnectorTaskStatus.RUNNING,
+    worker_id: 'kafka-connect0:8083',
+  },
+  config: {
+    'connector.class': 'FileStreamSource',
+    'tasks.max': '10',
+    topic: 'test-topic',
+    file: '/some/file',
+  },
+  tasks: [{ connector: 'first', task: 1 }],
+};
+
+export const connector: Connector = {
+  connect: 'first',
+  name: 'hdfs-source-connector',
+  type: ConnectorType.SOURCE,
+  status: {
+    state: ConnectorTaskStatus.RUNNING,
+    workerId: 'kafka-connect0:8083',
+  },
+  config: {
+    'connector.class': 'FileStreamSource',
+    'tasks.max': '10',
+    topic: 'test-topic',
+    file: '/some/file',
+  },
+  tasks: [{ connector: 'first', task: 1 }],
+};
+
+export const tasksServerPayload = [
+  {
+    id: { connector: 'first', task: 1 },
+    status: {
+      id: 1,
+      state: ConnectorTaskStatus.RUNNING,
+      worker_id: 'kafka-connect0:8083',
+    },
+    config: {
+      'batch.size': '2000',
+      file: '/some/file',
+      'task.class': 'org.apache.kafka.connect.file.FileStreamSourceTask',
+      topic: 'test-topic',
+    },
+  },
+  {
+    id: { connector: 'first', task: 2 },
+    status: {
+      id: 2,
+      state: ConnectorTaskStatus.FAILED,
+      trace: 'Failure 1',
+      worker_id: 'kafka-connect0:8083',
+    },
+    config: {
+      'batch.size': '1000',
+      file: '/some/file2',
+      'task.class': 'org.apache.kafka.connect.file.FileStreamSourceTask',
+      topic: 'test-topic',
+    },
+  },
+  {
+    id: { connector: 'first', task: 3 },
+    status: {
+      id: 3,
+      state: ConnectorTaskStatus.RUNNING,
+      worker_id: 'kafka-connect0:8083',
+    },
+    config: {
+      'batch.size': '3000',
+      file: '/some/file3',
+      'task.class': 'org.apache.kafka.connect.file.FileStreamSourceTask',
+      topic: 'test-topic',
+    },
+  },
+];
+
+export const tasks: Task[] = [
+  {
+    id: { connector: 'first', task: 1 },
+    status: {
+      id: 1,
+      state: ConnectorTaskStatus.RUNNING,
+      workerId: 'kafka-connect0:8083',
+    },
+    config: {
+      'batch.size': '2000',
+      file: '/some/file',
+      'task.class': 'org.apache.kafka.connect.file.FileStreamSourceTask',
+      topic: 'test-topic',
+    },
+  },
+  {
+    id: { connector: 'first', task: 2 },
+    status: {
+      id: 2,
+      state: ConnectorTaskStatus.FAILED,
+      trace: 'Failure 1',
+      workerId: 'kafka-connect0:8083',
+    },
+    config: {
+      'batch.size': '1000',
+      file: '/some/file2',
+      'task.class': 'org.apache.kafka.connect.file.FileStreamSourceTask',
+      topic: 'test-topic',
+    },
+  },
+  {
+    id: { connector: 'first', task: 3 },
+    status: {
+      id: 3,
+      state: ConnectorTaskStatus.RUNNING,
+      workerId: 'kafka-connect0:8083',
+    },
+    config: {
+      'batch.size': '3000',
+      file: '/some/file3',
+      'task.class': 'org.apache.kafka.connect.file.FileStreamSourceTask',
+      topic: 'test-topic',
+    },
+  },
+];

+ 214 - 9
kafka-ui-react-app/src/redux/reducers/connect/__test__/reducer.spec.ts

@@ -1,24 +1,229 @@
+import { ConnectorTaskStatus } from 'generated-sources';
 import {
   fetchConnectorsAction,
   fetchConnectorAction,
   fetchConnectsAction,
+  fetchConnectorTasksAction,
+  fetchConnectorConfigAction,
+  createConnectorAction,
+  deleteConnectorAction,
+  pauseConnectorAction,
+  resumeConnectorAction,
+  updateConnectorConfigAction,
 } from 'redux/actions';
 import reducer, { initialState } from 'redux/reducers/connect/reducer';
 
+import { connects, connectors, connector, tasks } from './fixtures';
+
+const runningConnectorState = {
+  ...initialState,
+  currentConnector: {
+    ...initialState.currentConnector,
+    connector: {
+      ...connector,
+      status: {
+        ...connector.status,
+        state: ConnectorTaskStatus.RUNNING,
+      },
+    },
+    tasks: tasks.map((task) => ({
+      ...task,
+      status: {
+        ...task.status,
+        state: ConnectorTaskStatus.RUNNING,
+      },
+    })),
+  },
+};
+
+const pausedConnectorState = {
+  ...initialState,
+  currentConnector: {
+    ...initialState.currentConnector,
+    connector: {
+      ...connector,
+      status: {
+        ...connector.status,
+        state: ConnectorTaskStatus.PAUSED,
+      },
+    },
+    tasks: tasks.map((task) => ({
+      ...task,
+      status: {
+        ...task.status,
+        state: ConnectorTaskStatus.PAUSED,
+      },
+    })),
+  },
+};
+
 describe('Clusters reducer', () => {
-  it('reacts on GET_CONNECTS__SUCCESS and returns payload', () => {
+  it('reacts on GET_CONNECTS__SUCCESS', () => {
+    expect(
+      reducer(initialState, fetchConnectsAction.success({ connects }))
+    ).toEqual({
+      ...initialState,
+      connects,
+    });
+  });
+
+  it('reacts on GET_CONNECTORS__SUCCESS', () => {
+    expect(
+      reducer(initialState, fetchConnectorsAction.success({ connectors }))
+    ).toEqual({
+      ...initialState,
+      connectors,
+    });
+  });
+
+  it('reacts on GET_CONNECTOR__SUCCESS', () => {
+    expect(
+      reducer(initialState, fetchConnectorAction.success({ connector }))
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        connector,
+      },
+    });
+  });
+
+  it('reacts on POST_CONNECTOR__SUCCESS', () => {
+    expect(
+      reducer(initialState, createConnectorAction.success({ connector }))
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        connector,
+      },
+    });
+  });
+
+  it('reacts on DELETE_CONNECTOR__SUCCESS', () => {
+    expect(
+      reducer(
+        {
+          ...initialState,
+          connectors,
+        },
+        deleteConnectorAction.success({ connectorName: connectors[0].name })
+      )
+    ).toEqual({
+      ...initialState,
+      connectors: connectors.slice(1),
+    });
+  });
+
+  it('reacts on PAUSE_CONNECTOR__SUCCESS', () => {
+    expect(
+      reducer(
+        runningConnectorState,
+        pauseConnectorAction.success({ connectorName: connector.name })
+      )
+    ).toEqual(pausedConnectorState);
+  });
+
+  it('reacts on PAUSE_CONNECTOR__SUCCESS when current connector is null', () => {
+    expect(
+      reducer(
+        {
+          ...initialState,
+          currentConnector: {
+            ...initialState.currentConnector,
+            connector: null,
+          },
+        },
+        pauseConnectorAction.success({ connectorName: connector.name })
+      )
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        connector: null,
+      },
+    });
+  });
+
+  it('reacts on RESUME_CONNECTOR__SUCCESS', () => {
     expect(
-      reducer(undefined, fetchConnectsAction.success(initialState))
-    ).toEqual(initialState);
+      reducer(
+        pausedConnectorState,
+        resumeConnectorAction.success({ connectorName: connector.name })
+      )
+    ).toEqual(runningConnectorState);
   });
-  it('reacts on GET_CONNECTORS__SUCCESS and returns payload', () => {
+
+  it('reacts on RESUME_CONNECTOR__SUCCESS when current connector is null', () => {
+    expect(
+      reducer(
+        {
+          ...initialState,
+          currentConnector: {
+            ...initialState.currentConnector,
+            connector: null,
+          },
+        },
+        resumeConnectorAction.success({ connectorName: connector.name })
+      )
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        connector: null,
+      },
+    });
+  });
+
+  it('reacts on GET_CONNECTOR_TASKS__SUCCESS', () => {
     expect(
-      reducer(undefined, fetchConnectorsAction.success(initialState))
-    ).toEqual(initialState);
+      reducer(initialState, fetchConnectorTasksAction.success({ tasks }))
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        tasks,
+      },
+    });
   });
-  it('reacts on GET_CONNECTOR__SUCCESS and returns payload', () => {
+
+  it('reacts on GET_CONNECTOR_CONFIG__SUCCESS', () => {
+    expect(
+      reducer(
+        initialState,
+        fetchConnectorConfigAction.success({ config: connector.config })
+      )
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        config: connector.config,
+      },
+    });
+  });
+
+  it('reacts on PATCH_CONNECTOR_CONFIG__SUCCESS', () => {
     expect(
-      reducer(undefined, fetchConnectorAction.success(initialState))
-    ).toEqual(initialState);
+      reducer(
+        {
+          ...initialState,
+          currentConnector: {
+            ...initialState.currentConnector,
+            config: {
+              ...connector.config,
+              fieldToRemove: 'Fake',
+            },
+          },
+        },
+        updateConnectorConfigAction.success({ connector })
+      )
+    ).toEqual({
+      ...initialState,
+      currentConnector: {
+        ...initialState.currentConnector,
+        connector,
+        config: connector.config,
+      },
+    });
   });
 });

+ 92 - 0
kafka-ui-react-app/src/redux/reducers/connect/__test__/selectors.spec.ts

@@ -0,0 +1,92 @@
+import {
+  fetchConnectorAction,
+  fetchConnectorConfigAction,
+  fetchConnectorsAction,
+  fetchConnectorTasksAction,
+  fetchConnectsAction,
+} from 'redux/actions';
+import configureStore from 'redux/store/configureStore';
+import * as selectors from 'redux/reducers/connect/selectors';
+
+import { connects, connectors, connector, tasks } from './fixtures';
+
+const store = configureStore();
+
+describe('Connect selectors', () => {
+  describe('Initial State', () => {
+    it('returns initial values', () => {
+      expect(selectors.getAreConnectsFetching(store.getState())).toEqual(false);
+      expect(selectors.getConnects(store.getState())).toEqual([]);
+      expect(selectors.getAreConnectorsFetching(store.getState())).toEqual(
+        false
+      );
+      expect(selectors.getConnectors(store.getState())).toEqual([]);
+      expect(selectors.getIsConnectorFetching(store.getState())).toEqual(false);
+      expect(selectors.getConnector(store.getState())).toEqual(null);
+      expect(selectors.getConnectorStatus(store.getState())).toEqual(undefined);
+      expect(selectors.getIsConnectorDeleting(store.getState())).toEqual(false);
+      expect(selectors.getIsConnectorRestarting(store.getState())).toEqual(
+        false
+      );
+      expect(selectors.getIsConnectorPausing(store.getState())).toEqual(false);
+      expect(selectors.getIsConnectorResuming(store.getState())).toEqual(false);
+      expect(selectors.getIsConnectorActionRunning(store.getState())).toEqual(
+        false
+      );
+      expect(selectors.getAreConnectorTasksFetching(store.getState())).toEqual(
+        false
+      );
+      expect(selectors.getConnectorTasks(store.getState())).toEqual([]);
+      expect(selectors.getConnectorRunningTasksCount(store.getState())).toEqual(
+        0
+      );
+      expect(selectors.getConnectorFailedTasksCount(store.getState())).toEqual(
+        0
+      );
+      expect(selectors.getIsConnectorConfigFetching(store.getState())).toEqual(
+        false
+      );
+      expect(selectors.getConnectorConfig(store.getState())).toEqual(null);
+    });
+  });
+
+  describe('state', () => {
+    it('returns connects', () => {
+      store.dispatch(fetchConnectsAction.success({ connects }));
+      expect(selectors.getConnects(store.getState())).toEqual(connects);
+    });
+
+    it('returns connectors', () => {
+      store.dispatch(fetchConnectorsAction.success({ connectors }));
+      expect(selectors.getConnectors(store.getState())).toEqual(connectors);
+    });
+
+    it('returns connector', () => {
+      store.dispatch(fetchConnectorAction.success({ connector }));
+      expect(selectors.getConnector(store.getState())).toEqual(connector);
+      expect(selectors.getConnectorStatus(store.getState())).toEqual(
+        connector.status.state
+      );
+    });
+
+    it('returns connector tasks', () => {
+      store.dispatch(fetchConnectorTasksAction.success({ tasks }));
+      expect(selectors.getConnectorTasks(store.getState())).toEqual(tasks);
+      expect(selectors.getConnectorRunningTasksCount(store.getState())).toEqual(
+        2
+      );
+      expect(selectors.getConnectorFailedTasksCount(store.getState())).toEqual(
+        1
+      );
+    });
+
+    it('returns connector config', () => {
+      store.dispatch(
+        fetchConnectorConfigAction.success({ config: connector.config })
+      );
+      expect(selectors.getConnectorConfig(store.getState())).toEqual(
+        connector.config
+      );
+    });
+  });
+});

+ 100 - 2
kafka-ui-react-app/src/redux/reducers/connect/reducer.ts

@@ -2,19 +2,117 @@ import { getType } from 'typesafe-actions';
 import * as actions from 'redux/actions';
 import { ConnectState } from 'redux/interfaces/connect';
 import { Action } from 'redux/interfaces';
+import { ConnectorTaskStatus } from 'generated-sources';
 
 export const initialState: ConnectState = {
   connects: [],
   connectors: [],
+  currentConnector: {
+    connector: null,
+    tasks: [],
+    config: null,
+  },
 };
 
 const reducer = (state = initialState, action: Action): ConnectState => {
   switch (action.type) {
     case getType(actions.fetchConnectsAction.success):
-    case getType(actions.fetchConnectorAction.success):
+      return {
+        ...state,
+        connects: action.payload.connects,
+      };
     case getType(actions.fetchConnectorsAction.success):
+      return {
+        ...state,
+        connectors: action.payload.connectors,
+      };
+    case getType(actions.fetchConnectorAction.success):
+    case getType(actions.createConnectorAction.success):
+      return {
+        ...state,
+        currentConnector: {
+          ...state.currentConnector,
+          connector: action.payload.connector,
+        },
+      };
     case getType(actions.deleteConnectorAction.success):
-      return action.payload;
+      return {
+        ...state,
+        connectors: state?.connectors.filter(
+          ({ name }) => name !== action.payload.connectorName
+        ),
+      };
+    case getType(actions.pauseConnectorAction.success):
+      return {
+        ...state,
+        currentConnector: {
+          ...state.currentConnector,
+          connector: state.currentConnector.connector
+            ? {
+                ...state.currentConnector.connector,
+                status: {
+                  ...state.currentConnector.connector?.status,
+                  state: ConnectorTaskStatus.PAUSED,
+                },
+              }
+            : null,
+          tasks: state.currentConnector.tasks.map((task) => ({
+            ...task,
+            status: {
+              ...task.status,
+              state: ConnectorTaskStatus.PAUSED,
+            },
+          })),
+        },
+      };
+    case getType(actions.resumeConnectorAction.success):
+      return {
+        ...state,
+        currentConnector: {
+          ...state.currentConnector,
+          connector: state.currentConnector.connector
+            ? {
+                ...state.currentConnector.connector,
+                status: {
+                  ...state.currentConnector.connector?.status,
+                  state: ConnectorTaskStatus.RUNNING,
+                },
+              }
+            : null,
+          tasks: state.currentConnector.tasks.map((task) => ({
+            ...task,
+            status: {
+              ...task.status,
+              state: ConnectorTaskStatus.RUNNING,
+            },
+          })),
+        },
+      };
+    case getType(actions.fetchConnectorTasksAction.success):
+      return {
+        ...state,
+        currentConnector: {
+          ...state.currentConnector,
+          tasks: action.payload.tasks,
+        },
+      };
+    case getType(actions.fetchConnectorConfigAction.success):
+      return {
+        ...state,
+        currentConnector: {
+          ...state.currentConnector,
+          config: action.payload.config,
+        },
+      };
+    case getType(actions.updateConnectorConfigAction.success):
+      return {
+        ...state,
+        currentConnector: {
+          ...state.currentConnector,
+          connector: action.payload.connector,
+          config: action.payload.connector.config,
+        },
+      };
     default:
       return state;
   }

+ 102 - 15
kafka-ui-react-app/src/redux/reducers/connect/selectors.ts

@@ -1,27 +1,14 @@
 import { createSelector } from 'reselect';
 import { ConnectState, RootState } from 'redux/interfaces';
 import { createFetchingSelector } from 'redux/reducers/loader/selectors';
+import { ConnectorTaskStatus } from 'generated-sources';
 
 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'
+  (status) => status === 'fetching'
 );
 
 export const getConnects = createSelector(
@@ -29,7 +16,107 @@ export const getConnects = createSelector(
   ({ connects }) => connects
 );
 
+const getConnectorsFetchingStatus = createFetchingSelector('GET_CONNECTORS');
+export const getAreConnectorsFetching = createSelector(
+  getConnectorsFetchingStatus,
+  (status) => status === 'fetching'
+);
+
 export const getConnectors = createSelector(
   connectState,
   ({ connectors }) => connectors
 );
+
+const getConnectorFetchingStatus = createFetchingSelector('GET_CONNECTOR');
+export const getIsConnectorFetching = createSelector(
+  getConnectorFetchingStatus,
+  (status) => status === 'fetching'
+);
+
+const getCurrentConnector = createSelector(
+  connectState,
+  ({ currentConnector }) => currentConnector
+);
+
+export const getConnector = createSelector(
+  getCurrentConnector,
+  ({ connector }) => connector
+);
+
+export const getConnectorStatus = createSelector(
+  getConnector,
+  (connector) => connector?.status?.state
+);
+
+const getConnectorDeletingStatus = createFetchingSelector('DELETE_CONNECTOR');
+export const getIsConnectorDeleting = createSelector(
+  getConnectorDeletingStatus,
+  (status) => status === 'fetching'
+);
+
+const getConnectorRestartingStatus = createFetchingSelector(
+  'RESTART_CONNECTOR'
+);
+export const getIsConnectorRestarting = createSelector(
+  getConnectorRestartingStatus,
+  (status) => status === 'fetching'
+);
+
+const getConnectorPausingStatus = createFetchingSelector('PAUSE_CONNECTOR');
+export const getIsConnectorPausing = createSelector(
+  getConnectorPausingStatus,
+  (status) => status === 'fetching'
+);
+
+const getConnectorResumingStatus = createFetchingSelector('RESUME_CONNECTOR');
+export const getIsConnectorResuming = createSelector(
+  getConnectorResumingStatus,
+  (status) => status === 'fetching'
+);
+
+export const getIsConnectorActionRunning = createSelector(
+  getIsConnectorRestarting,
+  getIsConnectorPausing,
+  getIsConnectorResuming,
+  (restarting, pausing, resuming) => restarting || pausing || resuming
+);
+
+const getConnectorTasksFetchingStatus = createFetchingSelector(
+  'GET_CONNECTOR_TASKS'
+);
+export const getAreConnectorTasksFetching = createSelector(
+  getConnectorTasksFetchingStatus,
+  (status) => status === 'fetching'
+);
+
+export const getConnectorTasks = createSelector(
+  getCurrentConnector,
+  ({ tasks }) => tasks
+);
+
+export const getConnectorRunningTasksCount = createSelector(
+  getConnectorTasks,
+  (tasks) =>
+    tasks.filter((task) => task.status?.state === ConnectorTaskStatus.RUNNING)
+      .length
+);
+
+export const getConnectorFailedTasksCount = createSelector(
+  getConnectorTasks,
+  (tasks) =>
+    tasks.filter((task) => task.status?.state === ConnectorTaskStatus.FAILED)
+      .length
+);
+
+const getConnectorConfigFetchingStatus = createFetchingSelector(
+  'GET_CONNECTOR_CONFIG'
+);
+export const getIsConnectorConfigFetching = createSelector(
+  getConnectorConfigFetchingStatus,
+  (status) => status === 'fetching'
+);
+
+export const getConnectorConfig = createSelector(
+  getCurrentConnector,
+  ({ config }) => config
+);

Some files were not shown because too many files changed in this diff