Implement consumer group deleting (#600)

* Implement consumer group deleting
This commit is contained in:
Alexander Krivonosov 2021-07-02 11:05:59 +03:00 committed by GitHub
parent 7482465943
commit 64a5985e81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 460 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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