Преглед на файлове

WIP: [issues#121] Topic Details: Display consumers (#448)

* [issues#121] Topic Details: Display consumers

* [issues#121] Topic Details: Display consumers

* [issues#121] Topic Details: Display consumers

* [issues#121] Topic Details: Display consumers

* [issues#121] Topic Details: Display consumers

* [issues#121] Topic Details: Display consumers

Co-authored-by: mbovtryuk <mbovtryuk@provectus.com>
TEDMykhailo преди 4 години
родител
ревизия
5d65967bc6

+ 53 - 0
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<ConsumerGroup>;
+  fetchTopicConsumerGroups(
+    clusterName: ClusterName,
+    topicName: TopicName
+  ): void;
+}
+
+const TopicConsumerGroups: React.FC<Props> = ({
+  consumerGroups,
+  fetchTopicConsumerGroups,
+  clusterName,
+  topicName,
+}) => {
+  React.useEffect(() => {
+    fetchTopicConsumerGroups(clusterName, topicName);
+  }, []);
+
+  return (
+    <div className="box">
+      {consumerGroups.length > 0 ? (
+        <table className="table is-striped is-fullwidth is-hoverable">
+          <thead>
+            <tr>
+              <th>Consumer group ID</th>
+              <th>Num of consumers</th>
+              <th>Num of topics</th>
+            </tr>
+          </thead>
+          <tbody>
+            {consumerGroups.map((consumerGroup) => (
+              <ListItem
+                key={consumerGroup.consumerGroupId}
+                consumerGroup={consumerGroup}
+              />
+            ))}
+          </tbody>
+        </table>
+      ) : (
+        'No active consumer groups'
+      )}
+    </div>
+  );
+};
+
+export default TopicConsumerGroups;

+ 34 - 0
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<RouteProps>;
+
+const mapStateToProps = (
+  state: RootState,
+  {
+    match: {
+      params: { topicName, clusterName },
+    },
+  }: OwnProps
+) => ({
+  consumerGroups: getTopicConsumerGroups(state),
+  topicName,
+  clusterName,
+});
+
+const mapDispatchToProps = {
+  fetchTopicConsumerGroups,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(TopicConsumerGroups)
+);

+ 43 - 0
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(
+      <ConsumerGroups
+        clusterName={mockClusterName}
+        consumerGroups={[]}
+        name={mockTopicName}
+        fetchTopicConsumerGroups={mockFn}
+        topicName={mockTopicName}
+      />
+    );
+
+    expect(component.exists('.table')).toBeFalsy();
+  });
+
+  it('render ConsumerGroups in Topic', () => {
+    const component = shallow(
+      <ConsumerGroups
+        clusterName={mockClusterName}
+        consumerGroups={mockWithConsumerGroup}
+        name={mockTopicName}
+        fetchTopicConsumerGroups={mockFn}
+        topicName={mockTopicName}
+      />
+    );
+
+    expect(component.exists('.table')).toBeTruthy();
+  });
+});

+ 15 - 0
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<Props> = ({
           >
             Messages
           </NavLink>
+          <NavLink
+            exact
+            to={clusterTopicConsumerGroupsPath(clusterName, topicName)}
+            className="navbar-item is-tab"
+            activeClassName="is-active"
+          >
+            Consumers
+          </NavLink>
           <NavLink
             exact
             to={clusterTopicSettingsPath(clusterName, topicName)}
@@ -128,6 +138,11 @@ const Details: React.FC<Props> = ({
           path="/ui/clusters/:clusterName/topics/:topicName"
           component={OverviewContainer}
         />
+        <Route
+          exact
+          path="/ui/clusters/:clusterName/topics/:topicName/consumergroups"
+          component={TopicConsumerGroupsContainer}
+        />
       </Switch>
     </div>
   );

+ 4 - 0
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) =>

+ 25 - 0
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({

+ 10 - 0
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: [],
+};

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

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

@@ -241,3 +241,9 @@ export const setTopicsSearchAction =
 export const setTopicsOrderByAction = createAction(
   'SET_TOPICS_ORDER_BY'
 )<TopicColumnsToSort>();
+
+export const fetchTopicConsumerGroupsAction = createAsyncAction(
+  'GET_TOPIC_CONSUMER_GROUPS__REQUEST',
+  'GET_TOPIC_CONSUMER_GROUPS__SUCCESS',
+  'GET_TOPIC_CONSUMER_GROUPS__FAILURE'
+)<undefined, TopicsState, undefined>();

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

+ 2 - 0
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'];

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

+ 2 - 0
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):

+ 2 - 0
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 =