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>
This commit is contained in:
TEDMykhailo 2021-05-14 10:23:04 +03:00 committed by GitHub
parent 69c034c793
commit 5d65967bc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 279 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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) =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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):

View file

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