[UI] Topic Details

This commit is contained in:
Oleg Shuralev 2020-01-12 01:53:04 +03:00
parent ea4b9dc0b4
commit ff852b390e
No known key found for this signature in database
GPG key ID: 0459DF80E1A2FD1B
26 changed files with 360 additions and 144 deletions

View file

@ -1489,9 +1489,9 @@
}
},
"@types/jest": {
"version": "24.0.24",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.24.tgz",
"integrity": "sha512-vgaG968EDPSJPMunEDdZvZgvxYSmeH8wKqBlHSkBt1pV2XlLEVDzsj1ZhLuI4iG4Pv841tES61txSBF0obh4CQ==",
"version": "24.0.25",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.25.tgz",
"integrity": "sha512-hnP1WpjN4KbGEK4dLayul6lgtys6FPz0UfxMeMQCv0M+sTnzN3ConfiO72jHgLxl119guHgI8gLqDOrRLsyp2g==",
"requires": {
"jest-diff": "^24.3.0"
}
@ -1507,9 +1507,9 @@
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA=="
},
"@types/node": {
"version": "12.12.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.21.tgz",
"integrity": "sha512-8sRGhbpU+ck1n0PGAUgVrWrWdjSW2aqNeyC15W88GRsMpSwzv6RJGlLhE7s2RhVSOdyDmxbqlWSeThq4/7xqlA=="
"version": "12.12.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.24.tgz",
"integrity": "sha512-1Ciqv9pqwVtW6FsIUKSZNB82E5Cu1I2bBTj1xuIHXLe/1zYLl3956Nbhg2MzSYHVfl9/rmanjbQIb7LibfCnug=="
},
"@types/parse-json": {
"version": "4.0.0",
@ -1527,9 +1527,9 @@
"integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw=="
},
"@types/react": {
"version": "16.9.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.16.tgz",
"integrity": "sha512-dQ3wlehuBbYlfvRXfF5G+5TbZF3xqgkikK7DWAsQXe2KnzV+kjD4W2ea+ThCrKASZn9h98bjjPzoTYzfRqyBkw==",
"version": "16.9.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.17.tgz",
"integrity": "sha512-UP27In4fp4sWF5JgyV6pwVPAQM83Fj76JOcg02X5BZcpSu5Wx+fP9RMqc2v0ssBoQIFvD5JdKY41gjJJKmw6Bg==",
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
@ -11745,9 +11745,9 @@
}
},
"redux": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz",
"integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
"integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
"requires": {
"loose-envify": "^1.4.0",
"symbol-observable": "^1.2.0"

View file

@ -7,9 +7,9 @@
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/classnames": "^2.2.9",
"@types/jest": "^24.0.0",
"@types/node": "^12.0.0",
"@types/react": "^16.9.0",
"@types/jest": "^24.0.25",
"@types/node": "^12.12.24",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.5",
"@types/react-router-dom": "^5.1.3",
@ -24,7 +24,7 @@
"react-redux": "^7.1.3",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.0",
"redux": "^4.0.4",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"typesafe-actions": "^5.1.0",

View file

@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { Cluster } from 'types';
import { NavLink } from 'react-router-dom';
import { clusterBrokersPath, clusterTopicsPath } from 'lib/paths';
interface Props extends Cluster {}
@ -26,15 +27,15 @@ const ClusterMenu: React.FC<Props> = ({
}) => (
<ul className="menu-list">
<li>
<NavLink exact to={`/clusters/${id}/brokers`} title={name} className="has-text-overflow-ellipsis">
<NavLink exact to={clusterBrokersPath(id)} title={name} className="has-text-overflow-ellipsis">
{defaultCluster && <DefaultIcon />}
{name}
</NavLink>
<ul>
<NavLink to={`/clusters/${id}/brokers`} activeClassName="is-active" title="Brokers">
<NavLink to={clusterBrokersPath(id)} activeClassName="is-active" title="Brokers">
Brokers
</NavLink>
<NavLink to={`/clusters/${id}/topics`} activeClassName="is-active" title="Topics">
<NavLink to={clusterTopicsPath(id)} activeClassName="is-active" title="Topics">
Topics
</NavLink>
</ul>

View file

@ -1,13 +0,0 @@
import React from 'react';
const ConfigRow: React.FC<{name: string, value: string}> = ({
name,
value,
}) => (
<tr>
<td>{name}</td>
<td>{value}</td>
</tr>
);
export default ConfigRow;

View file

@ -1,35 +1,59 @@
import React from 'react';
import { ClusterId, Topic, TopicDetails } from 'types';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import Indicator from 'components/common/Dashboard/Indicator';
import cx from 'classnames';
import { ClusterId, Topic, TopicDetails, TopicName } from 'types';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { NavLink, Switch, Route } from 'react-router-dom';
import { clusterTopicsPath, clusterTopicSettingsPath, clusterTopicPath, clusterTopicMessagesPath } from 'lib/paths';
import OverviewContainer from './Overview/OverviewContainer';
import MessagesContainer from './Messages/MessagesContainer';
import SettingsContainer from './Settings/SettingsContainer';
interface Props extends Topic, TopicDetails {
clusterId: ClusterId;
topicName: TopicName;
fetchTopicDetails: (clusterId: ClusterId, topicName: TopicName) => void;
}
const Details: React.FC<Props> = ({
clusterId,
name,
partitions,
internal,
topicName,
}) => {
return (
<div className="section">
<Breadcrumb links={[
{ href: `/clusters/${clusterId}/topics`, label: 'All Topics' },
]}>
{name}
</Breadcrumb>
<div className="level">
<div className="level-item level-left">
<Breadcrumb links={[
{ href: clusterTopicsPath(clusterId), label: 'All Topics' },
]}>
{topicName}
</Breadcrumb>
</div>
<div className="level-item level-right">
<MetricsWrapper title="Partitions">
<Indicator title="Under replicated partitions">
0
</Indicator>
<Indicator title="Out of sync replicas">
0
</Indicator>
</MetricsWrapper>
</div>
</div>
<div className="box">
<div className="tabs">
<ul>
<li className="is-active">
<NavLink exact to={clusterTopicPath(clusterId, topicName)}>Overview</NavLink>
</li>
<li>
<NavLink exact to={clusterTopicMessagesPath(clusterId, topicName)}>Messages</NavLink>
</li>
<li>
<NavLink exact to={clusterTopicSettingsPath(clusterId, topicName)}>Settings</NavLink>
</li>
</ul>
</div>
<Switch>
<Route exact path="/clusters/:clusterId/topics/:topicName/messages" component={MessagesContainer} />
<Route exact path="/clusters/:clusterId/topics/:topicName/settings" component={SettingsContainer} />
<Route exact path="/clusters/:clusterId/topics/:topicName" component={OverviewContainer} />
</Switch>
</div>
</div>
);
}

View file

@ -1,9 +1,9 @@
import { connect } from 'react-redux';
import {
fetchTopicList,
fetchTopicDetails,
} from 'redux/reducers/topics/thunks';
import Details from './Details';
import { RootState } from 'types';
import { RootState, TopicName, ClusterId } from 'types';
import { getTopicByName } from 'redux/reducers/topics/selectors';
import { withRouter, RouteComponentProps } from 'react-router-dom';
@ -16,13 +16,10 @@ interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { topicName, clusterId } } }: OwnProps) => ({
clusterId,
topicName,
...getTopicByName(state, topicName),
});
const mapDispatchToProps = {
fetchTopicList,
}
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Details)
connect(mapStateToProps)(Details)
);

View file

@ -0,0 +1,20 @@
import React from 'react';
import { ClusterId, TopicName } from 'types';
interface Props {
clusterId: ClusterId;
topicName: TopicName;
}
const Messages: React.FC<Props> = ({
clusterId,
topicName,
}) => {
return (
<h1>
Messages from {clusterId}{topicName}
</h1>
);
}
export default Messages;

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import Messages from './Messages';
import { RootState } from 'types';
import { withRouter, RouteComponentProps } from 'react-router-dom';
interface RouteProps {
clusterId: string;
topicName: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { topicName, clusterId } } }: OwnProps) => ({
clusterId,
topicName,
});
export default withRouter(
connect(mapStateToProps)(Messages)
);

View file

@ -0,0 +1,72 @@
import React from 'react';
import { ClusterId, Topic, TopicDetails, TopicName } from 'types';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import Indicator from 'components/common/Dashboard/Indicator';
interface Props extends Topic, TopicDetails {
isFetched: boolean;
clusterId: ClusterId;
topicName: TopicName;
fetchTopicDetails: (clusterId: ClusterId, topicName: TopicName) => void;
}
const Overview: React.FC<Props> = ({
isFetched,
clusterId,
topicName,
partitions,
underReplicatedPartitions,
inSyncReplicas,
replicas,
partitionCount,
replicationFactor,
fetchTopicDetails,
}) => {
React.useEffect(
() => { fetchTopicDetails(clusterId, topicName); },
[fetchTopicDetails, clusterId, topicName],
);
if (!isFetched) {
return null;
}
return (
<>
<MetricsWrapper wrapperClassName="notification">
<Indicator title="Partitions">
{partitionCount}
</Indicator>
<Indicator title="Replication Factor">
{replicationFactor}
</Indicator>
<Indicator title="Under replicated partitions">
{underReplicatedPartitions}
</Indicator>
<Indicator title="In sync replicas">
{inSyncReplicas}
<span className="subtitle has-text-weight-light"> of {replicas}</span>
</Indicator>
</MetricsWrapper>
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Partition ID</th>
<th>Broker leader</th>
</tr>
</thead>
<tbody>
{partitions.map(({ partition, leader }) => (
<tr>
<td>{partition}</td>
<td>{leader}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
export default Overview;

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import {
fetchTopicDetails,
} from 'redux/reducers/topics/thunks';
import Overview from './Overview';
import { RootState, TopicName, ClusterId } from 'types';
import { getTopicByName, getIsTopicDetailsFetched } from 'redux/reducers/topics/selectors';
import { withRouter, RouteComponentProps } from 'react-router-dom';
interface RouteProps {
clusterId: string;
topicName: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { topicName, clusterId } } }: OwnProps) => ({
clusterId,
topicName,
isFetched: getIsTopicDetailsFetched(state),
...getTopicByName(state, topicName),
});
const mapDispatchToProps = {
fetchTopicDetails: (clusterId: ClusterId, topicName: TopicName) => fetchTopicDetails(clusterId, topicName),
}
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Overview)
);

View file

@ -1,22 +0,0 @@
import React from 'react';
import { TopicPartition } from 'types';
import Replica from './Replica';
const Partition: React.FC<TopicPartition> = ({
partition,
leader,
replicas,
}) => {
return (
<div className="tile is-child box">
<h2 className="title is-5">Partition #{partition}</h2>
<div className="columns is-mobile is-multiline">
{replicas.map((replica, index) => <Replica {...replica} index={index} />)}
</div>
</div>
);
};
export default Partition;

View file

@ -1,34 +0,0 @@
import React from 'react';
import { TopicReplica } from 'types';
import cx from 'classnames';
interface Props extends TopicReplica {
index: number;
}
const Replica: React.FC<Props> = ({
inSync,
leader,
broker,
index,
}) => {
return (
<div className="column is-narrow">
<div className={cx('notification', leader ? 'is-warning' : 'is-light')}>
<div className="title is-6">Replica #{index}</div>
<div className="tags">
{leader && (
<span className="tag">
LEADER
</span>
)}
<span className={cx('tag', inSync ? 'is-success' : 'is-danger')}>
{inSync ? 'IN SYNC' : 'OUT OF SYNC'}
</span>
</div>
</div>
</div>
);
};
export default Replica;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { ClusterId, TopicName } from 'types';
interface Props {
clusterId: ClusterId;
topicName: TopicName;
}
const Sertings: React.FC<Props> = ({
clusterId,
topicName,
}) => {
return (
<h1>
Settings {clusterId}/{topicName}
</h1>
);
}
export default Sertings;

View file

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import {
fetchTopicDetails,
} from 'redux/reducers/topics/thunks';
import Settings from './Settings';
import { RootState } from 'types';
import { withRouter, RouteComponentProps } from 'react-router-dom';
interface RouteProps {
clusterId: string;
topicName: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { topicName, clusterId } } }: OwnProps) => ({
clusterId,
topicName,
});
export default withRouter(
connect(mapStateToProps)(Settings)
);

View file

@ -44,6 +44,7 @@ const List: React.FC<Props> = ({
<th>Topic Name</th>
<th>Total Partitions</th>
<th>Out of sync replicas</th>
<th>Type</th>
</tr>
</thead>
<tbody>

View file

@ -1,9 +1,11 @@
import React from 'react';
import cx from 'classnames';
import { NavLink } from 'react-router-dom';
import { Topic, TopicDetails } from 'types';
const ListItem: React.FC<Topic & TopicDetails> = ({
name,
internal,
partitions,
}) => {
const outOfSyncReplicas = React.useMemo(() => {
@ -26,6 +28,11 @@ const ListItem: React.FC<Topic & TopicDetails> = ({
</td>
<td>{partitions.length}</td>
<td>{outOfSyncReplicas}</td>
<td>
<div className={cx('tag is-small', internal ? 'is-light' : 'is-success')}>
{internal ? 'Internal' : 'External'}
</div>
</td>
</tr>
);
}

View file

@ -18,17 +18,15 @@ interface Props {
const Topics: React.FC<Props> = ({
clusterId,
isFetched,
fetchBrokers,
fetchTopicList,
}) => {
React.useEffect(() => { fetchTopicList(clusterId); }, [fetchTopicList, clusterId]);
// React.useEffect(() => { fetchBrokers(clusterId); }, [fetchBrokers, clusterId]);
if (isFetched) {
return (
<Switch>
<Route exact path="/clusters/:clusterId/topics/:topicName" component={DetailsContainer} />
<Route exact path="/clusters/:clusterId/topics" component={ListContainer} />
<Route path="/clusters/:clusterId/topics/:topicName" component={DetailsContainer} />
</Switch>
);
}

View file

@ -1,6 +1,5 @@
import { connect } from 'react-redux';
import { fetchTopicList } from 'redux/reducers/topics/thunks';
import { fetchBrokers } from 'redux/reducers/brokers/thunks';
import Topics from './Topics';
import { getIsTopicListFetched } from 'redux/reducers/topics/selectors';
import { RootState, ClusterId } from 'types';
@ -19,7 +18,6 @@ const mapStateToProps = (state: RootState, { match: { params: { clusterId } }}:
const mapDispatchToProps = {
fetchTopicList: (clusterId: ClusterId) => fetchTopicList(clusterId),
fetchBrokers: (clusterId: ClusterId) => fetchBrokers(clusterId),
}
export default connect(mapStateToProps, mapDispatchToProps)(Topics);

View file

@ -1,18 +1,23 @@
import React from 'react';
import cx from 'classnames';
interface Props {
title: string;
title?: string;
wrapperClassName?: string;
}
const MetricsWrapper: React.FC<Props> = ({
title,
children,
wrapperClassName,
}) => {
return (
<div className="box">
<h5 className="subtitle is-6">
{title}
</h5>
<div className={cx('box', wrapperClassName)}>
{title && (
<h5 className="subtitle is-6">
{title}
</h5>
)}
<div className="level">
{children}
</div>

View file

@ -2,14 +2,20 @@ import {
TopicName,
Topic,
ClusterId,
TopicDetails,
TopicConfigs,
} from 'types';
import {
BASE_URL,
BASE_PARAMS,
} from 'lib/constants';
export const getTopic = (name: TopicName): Promise<Topic> =>
fetch(`${BASE_URL}/topics/${name}`, { ...BASE_PARAMS })
export const getTopicConfig = (clusterId: ClusterId, topicName: TopicName): Promise<TopicConfigs> =>
fetch(`${BASE_URL}/clusters/${clusterId}/topics/${topicName}/config`, { ...BASE_PARAMS })
.then(res => res.json());
export const getTopicDetails = (clusterId: ClusterId, topicName: TopicName): Promise<TopicDetails> =>
fetch(`${BASE_URL}/clusters/${clusterId}/topics/${topicName}`, { ...BASE_PARAMS })
.then(res => res.json());
export const getTopics = (clusterId: ClusterId): Promise<Topic[]> =>

11
frontend/src/lib/paths.ts Normal file
View file

@ -0,0 +1,11 @@
import { ClusterId, TopicName } from "types";
const clusterPath = (clusterId: ClusterId) => `/clusters/${clusterId}`;
export const clusterBrokersPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/brokers`;
export const clusterTopicsPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/topics`;
export const clusterTopicPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}`;
export const clusterTopicSettingsPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}/settings`;
export const clusterTopicMessagesPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}/messages`;

View file

@ -2,6 +2,10 @@ enum ActionType {
GET_TOPICS__REQUEST = 'GET_TOPICS__REQUEST',
GET_TOPICS__SUCCESS = 'GET_TOPICS__SUCCESS',
GET_TOPICS__FAILURE = 'GET_TOPICS__FAILURE',
GET_TOPIC_DETAILS__REQUEST = 'GET_TOPIC_DETAILS__REQUEST',
GET_TOPIC_DETAILS__SUCCESS = 'GET_TOPIC_DETAILS__SUCCESS',
GET_TOPIC_DETAILS__FAILURE = 'GET_TOPIC_DETAILS__FAILURE',
}
export default ActionType;

View file

@ -1,9 +1,15 @@
import { createAsyncAction} from 'typesafe-actions';
import ActionType from './actionType';
import { Topic} from 'types';
import { Topic, TopicDetails, TopicName} from 'types';
export const fetchTopicListAction = createAsyncAction(
ActionType.GET_TOPICS__REQUEST,
ActionType.GET_TOPICS__SUCCESS,
ActionType.GET_TOPICS__FAILURE,
)<undefined, Topic[], undefined>();
export const fetchTopicDetailsAction = createAsyncAction(
ActionType.GET_TOPIC_DETAILS__REQUEST,
ActionType.GET_TOPIC_DETAILS__SUCCESS,
ActionType.GET_TOPIC_DETAILS__FAILURE,
)<undefined, { topicName: TopicName, details: TopicDetails }, undefined>();

View file

@ -1,4 +1,4 @@
import { Action, TopicsState } from 'types';
import { Action, TopicsState, Topic } from 'types';
import actionType from 'redux/reducers/actionType';
export const initialState: TopicsState = {
@ -6,22 +6,42 @@ export const initialState: TopicsState = {
allNames: [],
};
const updateTopicList = (state: TopicsState, payload: Topic[]) => {
const initialMemo: TopicsState = {
...state,
allNames: [],
}
return payload.reduce(
(memo: TopicsState, topic) => {
const { name } = topic;
memo.byName[name] = {
...memo.byName[name],
...topic,
};
memo.allNames.push(name);
return memo;
},
initialMemo,
);
}
const reducer = (state = initialState, action: Action): TopicsState => {
switch (action.type) {
case actionType.GET_TOPICS__SUCCESS:
return action.payload.reduce(
(memo, topic) => {
const { name } = topic;
memo.byName[name] = {
...memo.byName[name],
...topic,
};
memo.allNames.push(name);
return memo;
},
state,
);
return updateTopicList(state, action.payload);
case actionType.GET_TOPIC_DETAILS__SUCCESS:
return {
...state,
byName: {
...state.byName,
[action.payload.topicName]: {
...state.byName[action.payload.topicName],
...action.payload.details,
}
}
}
default:
return state;
}

View file

@ -8,12 +8,18 @@ const getAllNames = (state: RootState) => topicsState(state).allNames;
const getTopicMap = (state: RootState) => topicsState(state).byName;
const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
const getTopiDetailsFetchingStatus = createFetchingSelector('GET_TOPIC_DETAILS');
export const getIsTopicListFetched = createSelector(
getTopicListFetchingStatus,
(status) => status === FetchStatus.fetched,
);
export const getIsTopicDetailsFetched = createSelector(
getTopicListFetchingStatus,
(status) => status === FetchStatus.fetched,
);
export const getTopicList = createSelector(
getIsTopicListFetched,
getAllNames,

View file

@ -1,6 +1,12 @@
import { getTopics } from 'lib/api';
import { fetchTopicListAction } from './actions';
import { PromiseThunk, ClusterId } from 'types';
import {
getTopics,
getTopicDetails,
} from 'lib/api';
import {
fetchTopicListAction,
fetchTopicDetailsAction,
} from './actions';
import { PromiseThunk, ClusterId, TopicName } from 'types';
export const fetchTopicList = (clusterId: ClusterId): PromiseThunk<void> => async (dispatch) => {
dispatch(fetchTopicListAction.request());
@ -11,3 +17,13 @@ export const fetchTopicList = (clusterId: ClusterId): PromiseThunk<void> => asyn
dispatch(fetchTopicListAction.failure());
}
}
export const fetchTopicDetails = (clusterId: ClusterId, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
dispatch(fetchTopicDetailsAction.request());
try {
const topicDetails = await getTopicDetails(clusterId, topicName);
dispatch(fetchTopicDetailsAction.success({ topicName, details: topicDetails }));
} catch (e) {
dispatch(fetchTopicDetailsAction.failure());
}
}