Browse Source

Implement consumer group deleting (#600)

* Implement consumer group deleting
Alexander Krivonosov 4 years ago
parent
commit
64a5985e81

+ 43 - 6
kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx

@@ -9,31 +9,50 @@ import {
   ConsumerTopicPartitionDetail,
 } from 'generated-sources';
 import PageLoader from 'components/common/PageLoader/PageLoader';
+import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
+import { useHistory } from 'react-router';
 
 import ListItem from './ListItem';
 
-interface Props extends ConsumerGroup, ConsumerGroupDetails {
+export interface Props extends ConsumerGroup, ConsumerGroupDetails {
   clusterName: ClusterName;
-  consumerGroupID: ConsumerGroupID;
+  consumerGroupId: ConsumerGroupID;
   consumers?: ConsumerTopicPartitionDetail[];
   isFetched: boolean;
+  isDeleted: boolean;
   fetchConsumerGroupDetails: (
     clusterName: ClusterName,
     consumerGroupID: ConsumerGroupID
   ) => void;
+  deleteConsumerGroup: (clusterName: string, id: ConsumerGroupID) => void;
 }
 
 const Details: React.FC<Props> = ({
   clusterName,
-  consumerGroupID,
+  consumerGroupId,
   consumers,
   isFetched,
+  isDeleted,
   fetchConsumerGroupDetails,
+  deleteConsumerGroup,
 }) => {
   React.useEffect(() => {
-    fetchConsumerGroupDetails(clusterName, consumerGroupID);
-  }, [fetchConsumerGroupDetails, clusterName, consumerGroupID]);
+    fetchConsumerGroupDetails(clusterName, consumerGroupId);
+  }, [fetchConsumerGroupDetails, clusterName, consumerGroupId]);
   const items = consumers || [];
+  const [isConfirmationModelVisible, setIsConfirmationModelVisible] =
+    React.useState<boolean>(false);
+  const history = useHistory();
+
+  const onDelete = () => {
+    setIsConfirmationModelVisible(false);
+    deleteConsumerGroup(clusterName, consumerGroupId);
+  };
+  React.useEffect(() => {
+    if (isDeleted) {
+      history.push(clusterConsumerGroupsPath(clusterName));
+    }
+  }, [isDeleted]);
 
   return (
     <div className="section">
@@ -47,13 +66,24 @@ const Details: React.FC<Props> = ({
               },
             ]}
           >
-            {consumerGroupID}
+            {consumerGroupId}
           </Breadcrumb>
         </div>
       </div>
 
       {isFetched ? (
         <div className="box">
+          <div className="level">
+            <div className="level-item level-right buttons">
+              <button
+                type="button"
+                className="button is-danger"
+                onClick={() => setIsConfirmationModelVisible(true)}
+              >
+                Delete consumer group
+              </button>
+            </div>
+          </div>
           <table className="table is-striped is-fullwidth">
             <thead>
               <tr>
@@ -80,6 +110,13 @@ const Details: React.FC<Props> = ({
       ) : (
         <PageLoader />
       )}
+      <ConfirmationModal
+        isOpen={isConfirmationModelVisible}
+        onCancel={() => setIsConfirmationModelVisible(false)}
+        onConfirm={onDelete}
+      >
+        Are you sure you want to delete this consumer group?
+      </ConfirmationModal>
     </div>
   );
 };

+ 8 - 1
kafka-ui-react-app/src/components/ConsumerGroups/Details/DetailsContainer.ts

@@ -3,10 +3,14 @@ import { ClusterName, RootState } from 'redux/interfaces';
 import { withRouter, RouteComponentProps } from 'react-router-dom';
 import {
   getIsConsumerGroupDetailsFetched,
+  getIsConsumerGroupsDeleted,
   getConsumerGroupByID,
 } from 'redux/reducers/consumerGroups/selectors';
 import { ConsumerGroupID } from 'redux/interfaces/consumerGroup';
-import { fetchConsumerGroupDetails } from 'redux/actions/thunks';
+import {
+  deleteConsumerGroup,
+  fetchConsumerGroupDetails,
+} from 'redux/actions/thunks';
 
 import Details from './Details';
 
@@ -28,6 +32,7 @@ const mapStateToProps = (
   clusterName,
   consumerGroupID,
   isFetched: getIsConsumerGroupDetailsFetched(state),
+  isDeleted: getIsConsumerGroupsDeleted(state),
   ...getConsumerGroupByID(state, consumerGroupID),
 });
 
@@ -36,6 +41,8 @@ const mapDispatchToProps = {
     clusterName: ClusterName,
     consumerGroupID: ConsumerGroupID
   ) => fetchConsumerGroupDetails(clusterName, consumerGroupID),
+  deleteConsumerGroup: (clusterName: string, id: ConsumerGroupID) =>
+    deleteConsumerGroup(clusterName, id),
 };
 
 export default withRouter(

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

@@ -0,0 +1,105 @@
+import Details, { Props } from 'components/ConsumerGroups/Details/Details';
+import { mount, shallow } from 'enzyme';
+import React from 'react';
+import { StaticRouter } from 'react-router';
+
+const mockHistory = {
+  push: jest.fn(),
+};
+jest.mock('react-router', () => ({
+  ...jest.requireActual('react-router'),
+  useHistory: () => mockHistory,
+}));
+
+describe('Details component', () => {
+  const setupWrapper = (props?: Partial<Props>) => (
+    <Details
+      clusterName="local"
+      clusterId="local"
+      consumerGroupId="test"
+      isFetched
+      isDeleted={false}
+      fetchConsumerGroupDetails={jest.fn()}
+      deleteConsumerGroup={jest.fn()}
+      consumers={[
+        {
+          groupId: 'messages-consumer',
+          consumerId:
+            'consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0',
+          topic: 'messages',
+          host: '/172.31.9.153',
+          partition: 6,
+          currentOffset: 394,
+          endOffset: 394,
+          messagesBehind: 0,
+        },
+        {
+          groupId: 'messages-consumer',
+          consumerId:
+            'consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d1',
+          topic: 'messages',
+          host: '/172.31.9.153',
+          partition: 7,
+          currentOffset: 384,
+          endOffset: 384,
+          messagesBehind: 0,
+        },
+      ]}
+      {...props}
+    />
+  );
+  describe('when consumer gruops are NOT fetched', () => {
+    it('Matches the snapshot', () => {
+      expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot();
+    });
+  });
+
+  describe('when consumer gruops are fetched', () => {
+    it('Matches the snapshot', () => {
+      expect(shallow(setupWrapper())).toMatchSnapshot();
+    });
+
+    describe('onDelete', () => {
+      it('calls deleteConsumerGroup', () => {
+        const deleteConsumerGroup = jest.fn();
+        const component = mount(
+          <StaticRouter>{setupWrapper({ deleteConsumerGroup })}</StaticRouter>
+        );
+        component.find('button').at(0).simulate('click');
+        component.update();
+        component
+          .find('ConfirmationModal')
+          .find('button')
+          .at(1)
+          .simulate('click');
+        expect(deleteConsumerGroup).toHaveBeenCalledTimes(1);
+      });
+
+      describe('on ConfirmationModal cancel', () => {
+        it('does not call deleteConsumerGroup', () => {
+          const deleteConsumerGroup = jest.fn();
+          const component = mount(
+            <StaticRouter>{setupWrapper({ deleteConsumerGroup })}</StaticRouter>
+          );
+          component.find('button').at(0).simulate('click');
+          component.update();
+          component
+            .find('ConfirmationModal')
+            .find('button')
+            .at(0)
+            .simulate('click');
+          expect(deleteConsumerGroup).toHaveBeenCalledTimes(0);
+        });
+      });
+
+      describe('after deletion', () => {
+        it('calls history.push', () => {
+          mount(
+            <StaticRouter>{setupWrapper({ isDeleted: true })}</StaticRouter>
+          );
+          expect(mockHistory.push).toHaveBeenCalledTimes(1);
+        });
+      });
+    });
+  });
+});

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

@@ -0,0 +1,152 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Details component when consumer gruops are NOT fetched Matches the snapshot 1`] = `
+<div
+  className="section"
+>
+  <div
+    className="level"
+  >
+    <div
+      className="level-item level-left"
+    >
+      <Breadcrumb
+        links={
+          Array [
+            Object {
+              "href": "/ui/clusters/local/consumer-groups",
+              "label": "All Consumer Groups",
+            },
+          ]
+        }
+      >
+        test
+      </Breadcrumb>
+    </div>
+  </div>
+  <PageLoader />
+  <ConfirmationModal
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to delete this consumer group?
+  </ConfirmationModal>
+</div>
+`;
+
+exports[`Details component when consumer gruops are fetched Matches the snapshot 1`] = `
+<div
+  className="section"
+>
+  <div
+    className="level"
+  >
+    <div
+      className="level-item level-left"
+    >
+      <Breadcrumb
+        links={
+          Array [
+            Object {
+              "href": "/ui/clusters/local/consumer-groups",
+              "label": "All Consumer Groups",
+            },
+          ]
+        }
+      >
+        test
+      </Breadcrumb>
+    </div>
+  </div>
+  <div
+    className="box"
+  >
+    <div
+      className="level"
+    >
+      <div
+        className="level-item level-right buttons"
+      >
+        <button
+          className="button is-danger"
+          onClick={[Function]}
+          type="button"
+        >
+          Delete consumer group
+        </button>
+      </div>
+    </div>
+    <table
+      className="table is-striped is-fullwidth"
+    >
+      <thead>
+        <tr>
+          <th>
+            Consumer ID
+          </th>
+          <th>
+            Host
+          </th>
+          <th>
+            Topic
+          </th>
+          <th>
+            Partition
+          </th>
+          <th>
+            Messages behind
+          </th>
+          <th>
+            Current offset
+          </th>
+          <th>
+            End offset
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <ListItem
+          clusterName="local"
+          consumer={
+            Object {
+              "consumerId": "consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0",
+              "currentOffset": 394,
+              "endOffset": 394,
+              "groupId": "messages-consumer",
+              "host": "/172.31.9.153",
+              "messagesBehind": 0,
+              "partition": 6,
+              "topic": "messages",
+            }
+          }
+          key="consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0"
+        />
+        <ListItem
+          clusterName="local"
+          consumer={
+            Object {
+              "consumerId": "consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d1",
+              "currentOffset": 384,
+              "endOffset": 384,
+              "groupId": "messages-consumer",
+              "host": "/172.31.9.153",
+              "messagesBehind": 0,
+              "partition": 7,
+              "topic": "messages",
+            }
+          }
+          key="consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d1"
+        />
+      </tbody>
+    </table>
+  </div>
+  <ConfirmationModal
+    isOpen={false}
+    onCancel={[Function]}
+    onConfirm={[Function]}
+  >
+    Are you sure you want to delete this consumer group?
+  </ConfirmationModal>
+</div>
+`;

+ 27 - 0
kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts

@@ -4,6 +4,7 @@ import {
 } from 'redux/reducers/schemas/__test__/fixtures';
 import * as actions from 'redux/actions';
 import { TopicColumnsToSort } from 'generated-sources';
+import { FailurePayload } from 'redux/interfaces';
 
 import { mockTopicsState } from './fixtures';
 
@@ -175,4 +176,30 @@ describe('Actions', () => {
       });
     });
   });
+
+  describe('deleting consumer group', () => {
+    it('creates DELETE_CONSUMER_GROUP__REQUEST', () => {
+      expect(actions.deleteConsumerGroupAction.request()).toEqual({
+        type: 'DELETE_CONSUMER_GROUP__REQUEST',
+      });
+    });
+
+    it('creates DELETE_CONSUMER_GROUP__SUCCESS', () => {
+      expect(actions.deleteConsumerGroupAction.success('test')).toEqual({
+        type: 'DELETE_CONSUMER_GROUP__SUCCESS',
+        payload: 'test',
+      });
+    });
+
+    it('creates DELETE_CONSUMER_GROUP__FAILURE', () => {
+      const alert: FailurePayload = {
+        subject: ['consumer-group', 'test'].join('-'),
+        title: `Consumer Gropup Test`,
+      };
+      expect(actions.deleteConsumerGroupAction.failure({ alert })).toEqual({
+        type: 'DELETE_CONSUMER_GROUP__FAILURE',
+        payload: { alert },
+      });
+    });
+  });
 });

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

@@ -0,0 +1,53 @@
+import fetchMock from 'fetch-mock-jest';
+import * as actions from 'redux/actions/actions';
+import * as thunks from 'redux/actions/thunks';
+import { FailurePayload } from 'redux/interfaces';
+import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
+
+const store = mockStoreCreator;
+const clusterName = 'local';
+const id = 'test';
+
+describe('Consumer Groups Thunks', () => {
+  afterEach(() => {
+    fetchMock.restore();
+    store.clearActions();
+  });
+
+  describe('deleting consumer groups', () => {
+    it('calls DELETE_CONSUMER_GROUP__SUCCESS after successful delete', async () => {
+      fetchMock.deleteOnce(
+        `/api/clusters/${clusterName}/consumer-groups/${id}`,
+        200
+      );
+
+      await store.dispatch(thunks.deleteConsumerGroup(clusterName, id));
+      expect(store.getActions()).toEqual([
+        actions.deleteConsumerGroupAction.request(),
+        actions.deleteConsumerGroupAction.success(id),
+      ]);
+    });
+
+    it('calls DELETE_CONSUMER_GROUP__FAILURE after successful delete', async () => {
+      fetchMock.deleteOnce(
+        `/api/clusters/${clusterName}/consumer-groups/${id}`,
+        500
+      );
+
+      await store.dispatch(thunks.deleteConsumerGroup(clusterName, id));
+      const alert: FailurePayload = {
+        subject: ['consumer-group', id].join('-'),
+        title: `Consumer Gropup ${id}`,
+        response: {
+          body: undefined,
+          status: 500,
+          statusText: 'Internal Server Error',
+        },
+      };
+      expect(store.getActions()).toEqual([
+        actions.deleteConsumerGroupAction.request(),
+        actions.deleteConsumerGroupAction.failure({ alert }),
+      ]);
+    });
+  });
+});

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

@@ -119,6 +119,12 @@ export const fetchConsumerGroupDetailsAction = createAsyncAction(
   undefined
 >();
 
+export const deleteConsumerGroupAction = createAsyncAction(
+  'DELETE_CONSUMER_GROUP__REQUEST',
+  'DELETE_CONSUMER_GROUP__SUCCESS',
+  'DELETE_CONSUMER_GROUP__FAILURE'
+)<undefined, ConsumerGroupID, { alert?: FailurePayload }>();
+
 export const fetchSchemasByClusterNameAction = createAsyncAction(
   'GET_CLUSTER_SCHEMAS__REQUEST',
   'GET_CLUSTER_SCHEMAS__SUCCESS',

+ 26 - 0
kafka-ui-react-app/src/redux/actions/thunks/consumerGroups.ts

@@ -3,8 +3,10 @@ import {
   ConsumerGroupID,
   PromiseThunkResult,
   ClusterName,
+  FailurePayload,
 } from 'redux/interfaces';
 import { BASE_PARAMS } from 'lib/constants';
+import { getResponse } from 'lib/errorHandling';
 import * as actions from 'redux/actions/actions';
 
 const apiClientConf = new Configuration(BASE_PARAMS);
@@ -47,3 +49,27 @@ export const fetchConsumerGroupDetails =
       dispatch(actions.fetchConsumerGroupDetailsAction.failure());
     }
   };
+
+export const deleteConsumerGroup =
+  (
+    clusterName: ClusterName,
+    consumerGroupID: ConsumerGroupID
+  ): PromiseThunkResult =>
+  async (dispatch) => {
+    dispatch(actions.deleteConsumerGroupAction.request());
+    try {
+      await consumerGroupsApiClient.deleteConsumerGroup({
+        clusterName,
+        id: consumerGroupID,
+      });
+      dispatch(actions.deleteConsumerGroupAction.success(consumerGroupID));
+    } catch (e) {
+      const response = await getResponse(e);
+      const alert: FailurePayload = {
+        subject: ['consumer-group', consumerGroupID].join('-'),
+        title: `Consumer Gropup ${consumerGroupID}`,
+        response,
+      };
+      dispatch(actions.deleteConsumerGroupAction.failure({ alert }));
+    }
+  };

+ 26 - 0
kafka-ui-react-app/src/redux/reducers/consumerGroups/__test__/reducer.spec.ts

@@ -0,0 +1,26 @@
+import { ConsumerGroupsState } from 'redux/interfaces';
+import reducer from 'redux/reducers/consumerGroups/reducer';
+import * as actions from 'redux/actions';
+
+const state: ConsumerGroupsState = {
+  byID: {
+    test: {
+      clusterId: 'local',
+      consumerGroupId: 'test',
+    },
+  },
+  allIDs: ['test'],
+};
+
+describe('consumerGroup reducer', () => {
+  describe('consumer group deletion', () => {
+    it('correctly deletes a consumer group on deleteConsumerGroupAction.success', () => {
+      expect(
+        reducer(state, actions.deleteConsumerGroupAction.success('test'))
+      ).toEqual({
+        byID: {},
+        allIDs: [],
+      });
+    });
+  });
+});

+ 6 - 0
kafka-ui-react-app/src/redux/reducers/consumerGroups/reducer.ts

@@ -34,6 +34,7 @@ const updateConsumerGroupsList = (
 };
 
 const reducer = (state = initialState, action: Action): ConsumerGroupsState => {
+  let newState;
   switch (action.type) {
     case getType(actions.fetchConsumerGroupsAction.success):
       return updateConsumerGroupsList(state, action.payload);
@@ -48,6 +49,11 @@ const reducer = (state = initialState, action: Action): ConsumerGroupsState => {
           },
         },
       };
+    case getType(actions.deleteConsumerGroupAction.success):
+      newState = { ...state };
+      delete newState.byID[action.payload];
+      newState.allIDs = newState.allIDs.filter((id) => id !== action.payload);
+      return newState;
     default:
       return state;
   }

+ 8 - 0
kafka-ui-react-app/src/redux/reducers/consumerGroups/selectors.ts

@@ -21,12 +21,20 @@ const getConsumerGroupsListFetchingStatus = createFetchingSelector(
 const getConsumerGroupDetailsFetchingStatus = createFetchingSelector(
   'GET_CONSUMER_GROUP_DETAILS'
 );
+const getConsumerGroupDeletingStatus = createFetchingSelector(
+  'DELETE_CONSUMER_GROUP'
+);
 
 export const getIsConsumerGroupsListFetched = createSelector(
   getConsumerGroupsListFetchingStatus,
   (status) => status === 'fetched'
 );
 
+export const getIsConsumerGroupsDeleted = createSelector(
+  getConsumerGroupDeletingStatus,
+  (status) => status === 'fetched'
+);
+
 export const getIsConsumerGroupDetailsFetched = createSelector(
   getConsumerGroupDetailsFetchingStatus,
   (status) => status === 'fetched'