Forráskód Böngészése

[UI] Topic Details

Oleg Shuralev 5 éve
szülő
commit
ff852b390e
26 módosított fájl, 360 hozzáadás és 144 törlés
  1. 12 12
      frontend/package-lock.json
  2. 4 4
      frontend/package.json
  3. 4 3
      frontend/src/components/Nav/ClusterMenu.tsx
  4. 0 13
      frontend/src/components/Topics/Details/ConfigRow.tsx
  5. 43 19
      frontend/src/components/Topics/Details/Details.tsx
  6. 4 7
      frontend/src/components/Topics/Details/DetailsContainer.ts
  7. 20 0
      frontend/src/components/Topics/Details/Messages/Messages.tsx
  8. 20 0
      frontend/src/components/Topics/Details/Messages/MessagesContainer.ts
  9. 72 0
      frontend/src/components/Topics/Details/Overview/Overview.tsx
  10. 30 0
      frontend/src/components/Topics/Details/Overview/OverviewContainer.ts
  11. 0 22
      frontend/src/components/Topics/Details/Partition.tsx
  12. 0 34
      frontend/src/components/Topics/Details/Replica.tsx
  13. 20 0
      frontend/src/components/Topics/Details/Settings/Settings.tsx
  14. 23 0
      frontend/src/components/Topics/Details/Settings/SettingsContainer.ts
  15. 1 0
      frontend/src/components/Topics/List/List.tsx
  16. 7 0
      frontend/src/components/Topics/List/ListItem.tsx
  17. 1 3
      frontend/src/components/Topics/Topics.tsx
  18. 0 2
      frontend/src/components/Topics/TopicsContainer.ts
  19. 10 5
      frontend/src/components/common/Dashboard/MetricsWrapper.tsx
  20. 8 2
      frontend/src/lib/api/topics.ts
  21. 11 0
      frontend/src/lib/paths.ts
  22. 4 0
      frontend/src/redux/reducers/topics/actionType.ts
  23. 7 1
      frontend/src/redux/reducers/topics/actions.ts
  24. 34 14
      frontend/src/redux/reducers/topics/reducer.ts
  25. 6 0
      frontend/src/redux/reducers/topics/selectors.ts
  26. 19 3
      frontend/src/redux/reducers/topics/thunks.ts

+ 12 - 12
frontend/package-lock.json

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

+ 4 - 4
frontend/package.json

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

+ 4 - 3
frontend/src/components/Nav/ClusterMenu.tsx

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

+ 0 - 13
frontend/src/components/Topics/Details/ConfigRow.tsx

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

+ 43 - 19
frontend/src/components/Topics/Details/Details.tsx

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

+ 4 - 7
frontend/src/components/Topics/Details/DetailsContainer.ts

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

+ 20 - 0
frontend/src/components/Topics/Details/Messages/Messages.tsx

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

+ 20 - 0
frontend/src/components/Topics/Details/Messages/MessagesContainer.ts

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

+ 72 - 0
frontend/src/components/Topics/Details/Overview/Overview.tsx

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

+ 30 - 0
frontend/src/components/Topics/Details/Overview/OverviewContainer.ts

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

+ 0 - 22
frontend/src/components/Topics/Details/Partition.tsx

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

+ 0 - 34
frontend/src/components/Topics/Details/Replica.tsx

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

+ 20 - 0
frontend/src/components/Topics/Details/Settings/Settings.tsx

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

+ 23 - 0
frontend/src/components/Topics/Details/Settings/SettingsContainer.ts

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

+ 1 - 0
frontend/src/components/Topics/List/List.tsx

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

+ 7 - 0
frontend/src/components/Topics/List/ListItem.tsx

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

+ 1 - 3
frontend/src/components/Topics/Topics.tsx

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

+ 0 - 2
frontend/src/components/Topics/TopicsContainer.ts

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

+ 10 - 5
frontend/src/components/common/Dashboard/MetricsWrapper.tsx

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

+ 8 - 2
frontend/src/lib/api/topics.ts

@@ -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 - 0
frontend/src/lib/paths.ts

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

+ 4 - 0
frontend/src/redux/reducers/topics/actionType.ts

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

+ 7 - 1
frontend/src/redux/reducers/topics/actions.ts

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

+ 34 - 14
frontend/src/redux/reducers/topics/reducer.ts

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

+ 6 - 0
frontend/src/redux/reducers/topics/selectors.ts

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

+ 19 - 3
frontend/src/redux/reducers/topics/thunks.ts

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