diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/ConsumerGroups.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/ConsumerGroups.tsx new file mode 100644 index 00000000000..ec65254113e --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/ConsumerGroups.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Topic, TopicDetails, ConsumerGroup } from 'generated-sources'; +import { ClusterName, TopicName } from 'redux/interfaces'; +import ListItem from 'components/ConsumerGroups/List/ListItem'; + +interface Props extends Topic, TopicDetails { + clusterName: ClusterName; + topicName: TopicName; + consumerGroups: Array; + fetchTopicConsumerGroups( + clusterName: ClusterName, + topicName: TopicName + ): void; +} + +const TopicConsumerGroups: React.FC = ({ + consumerGroups, + fetchTopicConsumerGroups, + clusterName, + topicName, +}) => { + React.useEffect(() => { + fetchTopicConsumerGroups(clusterName, topicName); + }, []); + + return ( +
+ {consumerGroups.length > 0 ? ( + + + + + + + + + + {consumerGroups.map((consumerGroup) => ( + + ))} + +
Consumer group IDNum of consumersNum of topics
+ ) : ( + 'No active consumer groups' + )} +
+ ); +}; + +export default TopicConsumerGroups; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/ConsumerGroupsContainer.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/ConsumerGroupsContainer.tsx new file mode 100644 index 00000000000..a97c4b6b783 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/ConsumerGroupsContainer.tsx @@ -0,0 +1,34 @@ +import { connect } from 'react-redux'; +import { RootState, TopicName, ClusterName } from 'redux/interfaces'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { fetchTopicConsumerGroups } from 'redux/actions'; +import TopicConsumerGroups from 'components/Topics/Topic/Details/ConsumerGroups/ConsumerGroups'; +import { getTopicConsumerGroups } from 'redux/reducers/topics/selectors'; + +interface RouteProps { + clusterName: ClusterName; + topicName: TopicName; +} + +type OwnProps = RouteComponentProps; + +const mapStateToProps = ( + state: RootState, + { + match: { + params: { topicName, clusterName }, + }, + }: OwnProps +) => ({ + consumerGroups: getTopicConsumerGroups(state), + topicName, + clusterName, +}); + +const mapDispatchToProps = { + fetchTopicConsumerGroups, +}; + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(TopicConsumerGroups) +); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/__test__/ConsumerGroups.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/__test__/ConsumerGroups.spec.tsx new file mode 100644 index 00000000000..42ee3185887 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/__test__/ConsumerGroups.spec.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ConsumerGroups from 'components/Topics/Topic/Details/ConsumerGroups/ConsumerGroups'; + +describe('Details', () => { + const mockFn = jest.fn(); + const mockClusterName = 'local'; + const mockTopicName = 'local'; + const mockWithConsumerGroup = [ + { + clusterId: '1', + consumerGroupId: '1', + }, + ]; + + it("don't render ConsumerGroups in Topic", () => { + const component = shallow( + + ); + + expect(component.exists('.table')).toBeFalsy(); + }); + + it('render ConsumerGroups in Topic', () => { + const component = shallow( + + ); + + expect(component.exists('.table')).toBeTruthy(); + }); +}); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx index 62fcca38821..e4e5e8c2778 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx @@ -7,12 +7,14 @@ import { clusterTopicPath, clusterTopicMessagesPath, clusterTopicsPath, + clusterTopicConsumerGroupsPath, clusterTopicEditPath, } from 'lib/paths'; import ClusterContext from 'components/contexts/ClusterContext'; import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal'; import OverviewContainer from './Overview/OverviewContainer'; +import TopicConsumerGroupsContainer from './ConsumerGroups/ConsumerGroupsContainer'; import MessagesContainer from './Messages/MessagesContainer'; import SettingsContainer from './Settings/SettingsContainer'; @@ -64,6 +66,14 @@ const Details: React.FC = ({ > Messages + + Consumers + = ({ path="/ui/clusters/:clusterName/topics/:topicName" component={OverviewContainer} /> + ); diff --git a/kafka-ui-react-app/src/lib/paths.ts b/kafka-ui-react-app/src/lib/paths.ts index 2fea58a9c22..7829714451c 100644 --- a/kafka-ui-react-app/src/lib/paths.ts +++ b/kafka-ui-react-app/src/lib/paths.ts @@ -56,6 +56,10 @@ export const clusterTopicEditPath = ( clusterName: ClusterName, topicName: TopicName ) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`; +export const clusterTopicConsumerGroupsPath = ( + clusterName: ClusterName, + topicName: TopicName +) => `${clusterTopicsPath(clusterName)}/${topicName}/consumergroups`; // Kafka Connect export const clusterConnectsPath = (clusterName: ClusterName) => diff --git a/kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts b/kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts index 4b23d249b30..6b947bf6d9e 100644 --- a/kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts +++ b/kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts @@ -5,6 +5,8 @@ import { import * as actions from 'redux/actions'; import { TopicColumnsToSort } from 'generated-sources'; +import { mockTopicsState } from './fixtures'; + describe('Actions', () => { describe('fetchClusterStatsAction', () => { it('creates a REQUEST action', () => { @@ -133,6 +135,29 @@ describe('Actions', () => { }); }); + describe('fetchTopicConsumerGroups', () => { + it('creates a REQUEST action', () => { + expect(actions.fetchTopicConsumerGroupsAction.request()).toEqual({ + type: 'GET_TOPIC_CONSUMER_GROUPS__REQUEST', + }); + }); + + it('creates a SUCCESS action', () => { + expect( + actions.fetchTopicConsumerGroupsAction.success(mockTopicsState) + ).toEqual({ + type: 'GET_TOPIC_CONSUMER_GROUPS__SUCCESS', + payload: mockTopicsState, + }); + }); + + it('creates a FAILURE action', () => { + expect(actions.fetchTopicConsumerGroupsAction.failure()).toEqual({ + type: 'GET_TOPIC_CONSUMER_GROUPS__FAILURE', + }); + }); + }); + describe('setTopicsSearchAction', () => { it('creartes SET_TOPICS_SEARCH', () => { expect(actions.setTopicsSearchAction('test')).toEqual({ diff --git a/kafka-ui-react-app/src/redux/actions/__test__/fixtures.ts b/kafka-ui-react-app/src/redux/actions/__test__/fixtures.ts index 927ac3510a3..d2617f2301f 100644 --- a/kafka-ui-react-app/src/redux/actions/__test__/fixtures.ts +++ b/kafka-ui-react-app/src/redux/actions/__test__/fixtures.ts @@ -34,3 +34,13 @@ export const schema: SchemaSubject = { id: 1, compatibilityLevel: CompatibilityLevelCompatibilityEnum.BACKWARD, }; + +export const mockTopicsState = { + byName: {}, + allNames: [], + totalPages: 1, + messages: [], + search: '', + orderBy: null, + consumerGroups: [], +}; diff --git a/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts b/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts index fc47c4b1ae4..4b81d22a61b 100644 --- a/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts +++ b/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts @@ -2,6 +2,7 @@ import fetchMock from 'fetch-mock-jest'; import * as actions from 'redux/actions/actions'; import * as thunks from 'redux/actions/thunks'; import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator'; +import { mockTopicsState } from 'redux/actions/__test__/fixtures'; const store = mockStoreCreator; @@ -93,4 +94,42 @@ describe('Thunks', () => { } }); }); + + describe('fetchTopicConsumerGroups', () => { + it('GET_TOPIC_CONSUMER_GROUPS__FAILURE', async () => { + fetchMock.getOnce( + `api/clusters/${clusterName}/topics/${topicName}/consumergroups`, + 404 + ); + try { + await store.dispatch( + thunks.fetchTopicConsumerGroups(clusterName, topicName) + ); + } catch (error) { + expect(error.status).toEqual(404); + expect(store.getActions()).toEqual([ + actions.fetchTopicConsumerGroupsAction.request(), + actions.fetchTopicConsumerGroupsAction.failure(), + ]); + } + }); + + it('GET_TOPIC_CONSUMER_GROUPS__SUCCESS', async () => { + fetchMock.getOnce( + `api/clusters/${clusterName}/topics/${topicName}/consumergroups`, + 200 + ); + try { + await store.dispatch( + thunks.fetchTopicConsumerGroups(clusterName, topicName) + ); + } catch (error) { + expect(error.status).toEqual(200); + expect(store.getActions()).toEqual([ + actions.fetchTopicConsumerGroupsAction.request(), + actions.fetchTopicConsumerGroupsAction.success(mockTopicsState), + ]); + } + }); + }); }); diff --git a/kafka-ui-react-app/src/redux/actions/actions.ts b/kafka-ui-react-app/src/redux/actions/actions.ts index ca5769e81b0..e2fd011bb97 100644 --- a/kafka-ui-react-app/src/redux/actions/actions.ts +++ b/kafka-ui-react-app/src/redux/actions/actions.ts @@ -241,3 +241,9 @@ export const setTopicsSearchAction = export const setTopicsOrderByAction = createAction( 'SET_TOPICS_ORDER_BY' )(); + +export const fetchTopicConsumerGroupsAction = createAsyncAction( + 'GET_TOPIC_CONSUMER_GROUPS__REQUEST', + 'GET_TOPIC_CONSUMER_GROUPS__SUCCESS', + 'GET_TOPIC_CONSUMER_GROUPS__FAILURE' +)(); diff --git a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts index d47a3cec3ad..80bf43cc76d 100644 --- a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts +++ b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts @@ -8,6 +8,7 @@ import { TopicUpdate, TopicConfig, TopicColumnsToSort, + ConsumerGroupsApi, } from 'generated-sources'; import { PromiseThunkResult, @@ -26,6 +27,9 @@ import { getResponse } from 'lib/errorHandling'; const apiClientConf = new Configuration(BASE_PARAMS); export const topicsApiClient = new TopicsApi(apiClientConf); export const messagesApiClient = new MessagesApi(apiClientConf); +export const topicConsumerGroupsApiClient = new ConsumerGroupsApi( + apiClientConf +); export interface FetchTopicsListParams { clusterName: ClusterName; @@ -316,3 +320,32 @@ export const deleteTopic = dispatch(actions.deleteTopicAction.failure()); } }; + +export const fetchTopicConsumerGroups = + (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult => + async (dispatch, getState) => { + dispatch(actions.fetchTopicConsumerGroupsAction.request()); + try { + const consumerGroups = + await topicConsumerGroupsApiClient.getTopicConsumerGroups({ + clusterName, + topicName, + }); + const state = getState().topics; + const newState = { + ...state, + byName: { + ...state.byName, + [topicName]: { + ...state.byName[topicName], + consumerGroups: { + ...consumerGroups, + }, + }, + }, + }; + dispatch(actions.fetchTopicConsumerGroupsAction.success(newState)); + } catch (e) { + dispatch(actions.fetchTopicConsumerGroupsAction.failure()); + } + }; diff --git a/kafka-ui-react-app/src/redux/interfaces/topic.ts b/kafka-ui-react-app/src/redux/interfaces/topic.ts index fb5278c1704..90e7331897d 100644 --- a/kafka-ui-react-app/src/redux/interfaces/topic.ts +++ b/kafka-ui-react-app/src/redux/interfaces/topic.ts @@ -5,6 +5,7 @@ import { TopicConfig, TopicCreation, GetTopicMessagesRequest, + ConsumerGroup, TopicColumnsToSort, } from 'generated-sources'; @@ -48,6 +49,7 @@ export interface TopicsState { messages: TopicMessage[]; search: string; orderBy: TopicColumnsToSort | null; + consumerGroups: ConsumerGroup[]; } export type TopicFormFormattedParams = TopicCreation['configs']; diff --git a/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts b/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts index 0e064db94fa..167a0216503 100644 --- a/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts +++ b/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts @@ -4,6 +4,7 @@ import { clearMessagesTopicAction, setTopicsSearchAction, setTopicsOrderByAction, + fetchTopicConsumerGroupsAction, } from 'redux/actions'; import reducer from 'redux/reducers/topics/reducer'; @@ -21,6 +22,7 @@ const state = { totalPages: 1, search: '', orderBy: null, + consumerGroups: [], }; describe('topics reducer', () => { @@ -30,6 +32,7 @@ describe('topics reducer', () => { ...state, byName: {}, allNames: [], + consumerGroups: [], }); }); @@ -59,4 +62,12 @@ describe('topics reducer', () => { }); }); }); + + describe('topic consumer groups', () => { + it('GET_TOPIC_CONSUMER_GROUPS__SUCCESS', () => { + expect( + reducer(state, fetchTopicConsumerGroupsAction.success(state)) + ).toEqual(state); + }); + }); }); diff --git a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts index 85e1956f749..a0a67d5464c 100644 --- a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts +++ b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts @@ -10,6 +10,7 @@ export const initialState: TopicsState = { messages: [], search: '', orderBy: null, + consumerGroups: [], }; const transformTopicMessages = ( @@ -43,6 +44,7 @@ const reducer = (state = initialState, action: Action): TopicsState => { case getType(actions.fetchTopicDetailsAction.success): case getType(actions.fetchTopicConfigAction.success): case getType(actions.createTopicAction.success): + case getType(actions.fetchTopicConsumerGroupsAction.success): case getType(actions.updateTopicAction.success): return action.payload; case getType(actions.fetchTopicMessagesAction.success): diff --git a/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts b/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts index c5394a49174..16801fade4e 100644 --- a/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts +++ b/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts @@ -16,6 +16,8 @@ export const getTopicMessages = (state: RootState) => topicsState(state).messages; export const getTopicListTotalPages = (state: RootState) => topicsState(state).totalPages; +export const getTopicConsumerGroups = (state: RootState) => + topicsState(state).consumerGroups; const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS'); const getTopicDetailsFetchingStatus =