Oleg Shuralev před 5 roky
rodič
revize
1468adbc45

+ 18 - 81
frontend/src/components/App.scss

@@ -1,92 +1,29 @@
-$navbar-width: 64px;
+$header-height: 52px;
+$navbar-width: 250px;
 
 .Layout {
-  width: 100%;
-  height: 100%;
-  display: flex;
-  overflow: hidden;
-  position: relative;
-  background-color: #F7F7F7;
-
-  &__navbar {
-    display: flex;
-    z-index: 4;
-    flex-direction: column;
-    width: $navbar-width;
-    margin-left: 0;
-    background-color: #192d3e;
-    box-shadow: 0 0 0 0 rgba(0,0,0,0.2), 0 0 0 0 rgba(0,0,0,0.12), 0px 2px 7px 0px rgba(0,0,0,0.2);
-    text-align: center;
-
-    &Text {
-      display: none;
-    }
-
-    &--expanded {
-      margin-left: 0;
-      width: 280px;
-      text-align: left;
-
-      .Layout__navbar {
-        &Icon {
-          margin-right: 20px;
-        }
-
-        &Text {
-          display: inline;
-        }
-      }
-    }
-  }
-
-  &__logo {
-    height: 52px;
-    color: #fff;
-    text-align: center;
-    line-height: 52px;
+  &__header {
+    box-shadow: 0 0.46875rem 2.1875rem rgba(4,9,20,0.03),
+      0 0.9375rem 1.40625rem rgba(4,9,20,0.03),
+      0 0.25rem 0.53125rem rgba(4,9,20,0.05),
+      0 0.125rem 0.1875rem rgba(4,9,20,0.03);
   }
 
   &__container {
-    flex: 1 1 auto;
-    overflow: auto;
-    z-index: 2;
-  }
-
-  &__content {
-    margin-top: 52px;
+    margin-top: $header-height;
+    margin-left: $navbar-width;
   }
 
-  &__header {
+  &__navbar {
+    width: $navbar-width;
+    display: flex;
+    flex-direction: column;
+    box-shadow: 7px 0 60px rgba(0,0,0,0.05);
     position: fixed;
-    top: 0;
-    background-color: #F7F7F7;
-    width: 100%;
-    z-index: 3;
-    transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
-    box-shadow: 0 0 0 0 rgba(0,0,0,0.2), 0px 3px 5px 0px rgba(0,0,0,0.1), 0 0 0 0 rgba(0,0,0,0.12);
-  }
-}
-
-@media screen and (max-width: 1200px) {
-  .Layout__navbar {
-    margin-left: -$navbar-width;
-
-    &--expanded {
-      margin-left: 0;
-    }
-  }
-}
-
-@media screen and (max-width: 800px) {
-  .Layout__navbar {
-    margin-left: -$navbar-width;
+    top: $header-height;
+    left: 0;
+    bottom: 0;
+    padding: 20px 20px;
 
-    &--expanded {
-      margin-left: 0;
-      position: fixed;
-      top: 52px;
-      left: 0;
-      bottom: 0;
-    }
   }
 }

+ 32 - 45
frontend/src/components/App.tsx

@@ -2,60 +2,47 @@ import React from 'react';
 import {
   Switch,
   Route,
-  NavLink,
 } from 'react-router-dom';
 import './App.scss';
 import TopicsContainer from './Topics/TopicsContainer';
+import NavConatiner from './Nav/NavConatiner';
+import PageLoader from './common/PageLoader/PageLoader';
 
-const App: React.FC = () => {
-  const [expandedNavbar, setExpandedNavbar] = React.useState<boolean>(false);
-  const toggleNavbar = () => setExpandedNavbar(!expandedNavbar);
+interface AppProps {
+  isClusterListFetched: boolean;
+  fetchClustersList: () => void;
+}
+
+const App: React.FC<AppProps> = ({
+  isClusterListFetched,
+  fetchClustersList,
+}) => {
+  React.useEffect(() => { fetchClustersList() }, [fetchClustersList]);
 
   return (
     <div className="Layout">
-      <aside className={`Layout__navbar ${expandedNavbar && 'Layout__navbar--expanded'}`}>
-        <header className="Layout__logo">
-          Kafka UI
-        </header>
-        <div className="menu">
-          <ul className="menu-list">
-            <li>
-              <NavLink exact to="/" activeClassName="is-active">
-                <i className="fas fa-tachometer-alt Layout__navbarIcon"></i>
-                <span className="Layout__navbarText">
-                  Dashboard
-                </span>
-              </NavLink>
-            </li>
-            <li>
-              <NavLink to="/topics" activeClassName="is-active">
-                <i className="fas fa-stream Layout__navbarIcon"></i>
-                <span className="Layout__navbarText">
-                  Topics
-                </span>
-              </NavLink>
-            </li>
-          </ul>
+      <nav className="navbar is-fixed-top is-white Layout__header" role="navigation" aria-label="main navigation">
+        <div className="navbar-brand">
+          <a className="navbar-item title is-5 is-marginless" href="/">
+            Kafka UI
+          </a>
         </div>
-      </aside>
+      </nav>
       <main className="Layout__container">
-        <nav className="Layout__header navbar">
-          <div className="navbar-item">
-            <a title="Collapse" href="#" onClick={toggleNavbar}>
-              <span className="icon">
-                <i className="icon fas fa-bars"></i>
-              </span>
-            </a>
-          </div>
-        </nav>
-        <div className="Layout__content">
-          <Switch>
-            <Route path="/topics" component={TopicsContainer} />
-            <Route exact path="/">
-              Dashboard
-            </Route>
-          </Switch>
-        </div>
+        <NavConatiner className="Layout__navbar" />
+        {isClusterListFetched ? (
+          <section className="section">
+            <Switch>
+              <Route path="/clusters/:clusterId/topics" component={TopicsContainer} />
+              <Route exact path="/">
+                Dashboard
+              </Route>
+            </Switch>
+          </section>
+        ) : (
+          <PageLoader />
+        )}
+
       </main>
     </div>
   );

+ 17 - 0
frontend/src/components/AppContainer.tsx

@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import {
+  fetchClustersList,
+} from 'redux/reducers/clusters/thunks';
+import App from './App';
+import { getIsClusterListFetched } from 'redux/reducers/clusters/selectors';
+import { RootState } from 'types';
+
+const mapStateToProps = (state: RootState) => ({
+  isClusterListFetched: getIsClusterListFetched(state),
+});
+
+const mapDispatchToProps = {
+  fetchClustersList,
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(App);

+ 42 - 0
frontend/src/components/Nav/ClusterMenu.tsx

@@ -0,0 +1,42 @@
+import React, { CSSProperties } from 'react';
+import { Cluster } from 'types';
+import { NavLink } from 'react-router-dom';
+
+interface Props extends Cluster {}
+
+const DefaultIcon: React.FC = () => {
+  const style: CSSProperties = {
+    width: '.6rem',
+    left: '-8px',
+    top: '-4px',
+    position: 'relative',
+  };
+
+  return (
+    <span title="Default Cluster" className="icon has-text-primary is-small">
+      <i style={style} data-fa-transform="rotate-340" className="fas fa-thumbtack" />
+    </span>
+  )
+};
+
+const ClusterMenu: React.FC<Props> = ({
+  id,
+  name,
+  defaultCluster,
+}) => (
+  <ul className="menu-list">
+    <li>
+      <NavLink exact to={`/clusters/${id}`} activeClassName="is-active" title={name} className="has-text-overflow-ellipsis">
+        {defaultCluster && <DefaultIcon />}
+        {name}
+      </NavLink>
+      <ul>
+        <NavLink to={`/clusters/${id}/topics`} activeClassName="is-active" title="Dashboard">
+          Topics
+        </NavLink>
+      </ul>
+    </li>
+  </ul>
+);
+
+export default ClusterMenu;

+ 38 - 0
frontend/src/components/Nav/Nav.tsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import { Cluster } from 'types';
+import { NavLink } from 'react-router-dom';
+import cx from 'classnames';
+import ClusterMenu from './ClusterMenu';
+
+interface Props {
+  isClusterListFetched: boolean,
+  clusters: Cluster[];
+  className?: string;
+}
+
+const Nav: React.FC<Props> = ({
+  isClusterListFetched,
+  clusters,
+  className,
+}) => (
+  <aside className={cx('menu has-shadow has-background-white', className)}>
+    <p className="menu-label">
+      General
+    </p>
+    <ul className="menu-list">
+      <li>
+        <NavLink exact to="/" activeClassName="is-active" title="Dashboard">
+          Dashboard
+        </NavLink>
+      </li>
+    </ul>
+    <p className="menu-label">
+      Clusters
+    </p>
+    {!isClusterListFetched && <div className="loader" />}
+
+    {isClusterListFetched && clusters.map((cluster) => <ClusterMenu {...cluster} key={cluster.id}/>)}
+  </aside>
+);
+
+export default Nav;

+ 11 - 0
frontend/src/components/Nav/NavConatiner.ts

@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import Nav from './Nav';
+import { getIsClusterListFetched, getClusterList } from 'redux/reducers/clusters/selectors';
+import { RootState } from 'types';
+
+const mapStateToProps = (state: RootState) => ({
+  isClusterListFetched: getIsClusterListFetched(state),
+  clusters: getClusterList(state),
+});
+
+export default connect(mapStateToProps)(Nav);

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

@@ -2,7 +2,6 @@ import React from 'react';
 import { Topic } from 'types';
 import ConfigRow from './ConfigRow';
 import Partition from './Partition';
-import { NavLink } from 'react-router-dom';
 
 const Details: React.FC<{ topic: Topic }> = ({
   topic: {

+ 7 - 4
frontend/src/components/Topics/Topics.tsx

@@ -6,20 +6,23 @@ import {
 import ListContainer from './List/ListContainer';
 import DetailsContainer from './Details/DetailsContainer';
 import PageLoader from 'components/common/PageLoader/PageLoader';
+import { ClusterId } from 'types';
 
 interface Props {
+  clusterId: string;
   isFetched: boolean;
-  fetchBrokers: () => void;
-  fetchTopicList: () => void;
+  fetchBrokers: (clusterId: ClusterId) => void;
+  fetchTopicList: (clusterId: ClusterId) => void;
 }
 
 const Topics: React.FC<Props> = ({
+  clusterId,
   isFetched,
   fetchBrokers,
   fetchTopicList,
 }) => {
-  React.useEffect(() => { fetchTopicList(); }, [fetchTopicList]);
-  React.useEffect(() => { fetchBrokers(); }, [fetchBrokers]);
+  React.useEffect(() => { fetchTopicList(clusterId); }, [fetchTopicList, clusterId]);
+  React.useEffect(() => { fetchBrokers(clusterId); }, [fetchBrokers, clusterId]);
 
   if (isFetched) {
     return (

+ 12 - 4
frontend/src/components/Topics/TopicsContainer.ts

@@ -5,15 +5,23 @@ import {
 } from 'redux/reducers/topics/thunks';
 import Topics from './Topics';
 import { getIsTopicListFetched } from 'redux/reducers/topics/selectors';
-import { RootState } from 'types';
+import { RootState, ClusterId } from 'types';
+import { RouteComponentProps } from 'react-router-dom';
 
-const mapStateToProps = (state: RootState) => ({
+interface RouteProps {
+  clusterId: string;
+}
+
+interface OwnProps extends RouteComponentProps<RouteProps> { }
+
+const mapStateToProps = (state: RootState, { match: { params: { clusterId } }}: OwnProps) => ({
   isFetched: getIsTopicListFetched(state),
+  clusterId,
 });
 
 const mapDispatchToProps = {
-  fetchTopicList,
-  fetchBrokers,
+  fetchTopicList: (clusterId: ClusterId) => fetchTopicList(clusterId),
+  fetchBrokers: (clusterId: ClusterId) => fetchBrokers(clusterId),
 }
 
 export default connect(mapStateToProps, mapDispatchToProps)(Topics);

+ 2 - 2
frontend/src/index.tsx

@@ -4,7 +4,7 @@ import ReactDOM from 'react-dom';
 import { BrowserRouter } from 'react-router-dom';
 import { Provider } from 'react-redux';
 import './theme/index.scss';
-import App from './components/App';
+import AppContainer from './components/AppContainer';
 import * as serviceWorker from './serviceWorker';
 import configureStore from './redux/store/configureStore';
 
@@ -13,7 +13,7 @@ const store = configureStore();
 ReactDOM.render(
   <Provider store={store}>
     <BrowserRouter>
-      <App />
+      <AppContainer />
     </BrowserRouter>
   </Provider>,
   document.getElementById('root'),

+ 11 - 0
frontend/src/lib/api/clusters.ts

@@ -0,0 +1,11 @@
+import {
+  Cluster,
+} from 'types';
+import {
+  BASE_URL,
+  BASE_PARAMS,
+} from 'lib/constants';
+
+export const getClusters = (): Promise<Cluster[]> =>
+  fetch(`${BASE_URL}/clusters`, { ...BASE_PARAMS })
+    .then(res => res.json());

+ 1 - 0
frontend/src/lib/api/index.ts

@@ -1 +1,2 @@
 export * from './topics';
+export * from './clusters';

+ 9 - 15
frontend/src/lib/api/topics.ts

@@ -2,27 +2,21 @@ import {
   TopicName,
   Topic,
   Broker,
+  ClusterId,
 } from 'types';
-
-const BASE_PARAMS: RequestInit = {
-  credentials: 'include',
-  mode: 'cors',
-  headers: {
-    'Content-Type': 'application/json',
-    'Accept': 'application/vnd.kafka.v2+json',
-  },
-};
-
-const BASE_URL = 'http://localhost:8082';
+import {
+  BASE_URL,
+  BASE_PARAMS,
+} from 'lib/constants';
 
 export const getTopic = (name: TopicName): Promise<Topic> =>
   fetch(`${BASE_URL}/topics/${name}`, { ...BASE_PARAMS })
     .then(res => res.json());
 
-export const getTopics = (): Promise<TopicName[]> =>
-  fetch(`${BASE_URL}/topics`, { ...BASE_PARAMS })
+export const getTopics = (clusterId: ClusterId): Promise<TopicName[]> =>
+  fetch(`${BASE_URL}/clusters/${clusterId}/topics`, { ...BASE_PARAMS })
     .then(res => res.json());
 
-export const getBrokers = (): Promise<{ brokers: Broker[] }> =>
-  fetch(`${BASE_URL}/brokers`, { ...BASE_PARAMS })
+export const getBrokers = (clusterId: ClusterId): Promise<{ brokers: Broker[] }> =>
+  fetch(`${BASE_URL}/clusters/${clusterId}/brokers`, { ...BASE_PARAMS })
     .then(res => res.json());

+ 9 - 0
frontend/src/lib/constants.ts

@@ -0,0 +1,9 @@
+export const BASE_PARAMS: RequestInit = {
+  credentials: 'include',
+  mode: 'cors',
+  headers: {
+    'Content-Type': 'application/json',
+  },
+};
+
+export const BASE_URL = 'http://localhost:3004';

+ 5 - 1
frontend/src/redux/reducers/actionType.ts

@@ -1,3 +1,7 @@
 import topicsActionType from './topics/actionType';
+import clustersActionType from './clusters/actionType';
 
-export default { ...topicsActionType };
+export default {
+  ...topicsActionType,
+  ...clustersActionType,
+};

+ 7 - 0
frontend/src/redux/reducers/clusters/actionType.ts

@@ -0,0 +1,7 @@
+enum ActionType {
+  CLUSTERS__FETCH_REQUEST = 'CLUSTERS__FETCH_REQUEST',
+  CLUSTERS__FETCH_SUCCESS = 'CLUSTERS__FETCH_SUCCESS',
+  CLUSTERS__FETCH_FAILURE = 'CLUSTERS__FETCH_FAILURE',
+}
+
+export default ActionType;

+ 9 - 0
frontend/src/redux/reducers/clusters/actions.ts

@@ -0,0 +1,9 @@
+import { createAsyncAction} from 'typesafe-actions';
+import ActionType from './actionType';
+import { Cluster } from 'types';
+
+export const fetchClusterListAction = createAsyncAction(
+  ActionType.CLUSTERS__FETCH_REQUEST,
+  ActionType.CLUSTERS__FETCH_SUCCESS,
+  ActionType.CLUSTERS__FETCH_FAILURE,
+)<undefined, Cluster[], undefined>();

+ 33 - 0
frontend/src/redux/reducers/clusters/reducer.ts

@@ -0,0 +1,33 @@
+import { ClustersState, FetchStatus, Action } from 'types';
+import actionType from 'redux/reducers/actionType';
+
+export const initialState: ClustersState = {
+  fetchStatus: FetchStatus.notFetched,
+  items: [],
+};
+
+const reducer = (state = initialState, action: Action): ClustersState => {
+  switch (action.type) {
+    case actionType.CLUSTERS__FETCH_REQUEST:
+      return {
+        ...state,
+        fetchStatus: FetchStatus.fetching,
+      };
+    case actionType.CLUSTERS__FETCH_SUCCESS:
+      return {
+        ...state,
+        fetchStatus: FetchStatus.fetched,
+        items: action.payload,
+      };
+    case actionType.CLUSTERS__FETCH_FAILURE:
+      return {
+        ...state,
+        fetchStatus: FetchStatus.errorFetching,
+      };
+
+    default:
+      return state;
+  }
+};
+
+export default reducer;

+ 8 - 0
frontend/src/redux/reducers/clusters/selectors.ts

@@ -0,0 +1,8 @@
+import { createSelector } from 'reselect';
+import { ClustersState, RootState, FetchStatus } from 'types';
+
+const clustersState = ({ clusters }: RootState): ClustersState => clusters;
+
+export const getIsClusterListFetched = createSelector(clustersState, ({ fetchStatus }) => fetchStatus === FetchStatus.fetched);
+
+export const getClusterList = createSelector(clustersState, ({ items }) => items);

+ 19 - 0
frontend/src/redux/reducers/clusters/thunks.ts

@@ -0,0 +1,19 @@
+import {
+  getClusters,
+} from 'lib/api';
+import {
+  fetchClusterListAction,
+} from './actions';
+import { Cluster, PromiseThunk } from 'types';
+
+export const fetchClustersList = (): PromiseThunk<void> => async (dispatch) => {
+  dispatch(fetchClusterListAction.request());
+
+  try {
+    const clusters: Cluster[] = await getClusters();
+
+    dispatch(fetchClusterListAction.success(clusters));
+  } catch (e) {
+    dispatch(fetchClusterListAction.failure());
+  }
+}

+ 2 - 0
frontend/src/redux/reducers/index.ts

@@ -1,7 +1,9 @@
 import { combineReducers } from 'redux';
 import topics from './topics/reducer';
+import clusters from './clusters/reducer';
 import { RootState } from 'types';
 
 export default combineReducers<RootState>({
   topics,
+  clusters,
 });

+ 5 - 5
frontend/src/redux/reducers/topics/thunks.ts

@@ -7,14 +7,14 @@ import {
   fetchTopicListAction,
   fetchBrokersAction,
 } from './actions';
-import { Topic, TopicName, PromiseThunk } from 'types';
+import { Topic, TopicName, PromiseThunk, ClusterId } from 'types';
 
 
-export const fetchTopicList = (): PromiseThunk<void> => async (dispatch, getState) => {
+export const fetchTopicList = (clusterId: ClusterId): PromiseThunk<void> => async (dispatch) => {
   dispatch(fetchTopicListAction.request());
 
   try {
-    const topics = await getTopics();
+    const topics = await getTopics(clusterId);
     const detailedList = await Promise.all(topics.map((topic: TopicName): Promise<Topic> => getTopic(topic)));
 
     dispatch(fetchTopicListAction.success(detailedList));
@@ -23,10 +23,10 @@ export const fetchTopicList = (): PromiseThunk<void> => async (dispatch, getStat
   }
 }
 
-export const fetchBrokers = (): PromiseThunk<void> => async (dispatch, getState) => {
+export const fetchBrokers = (clusterId: ClusterId): PromiseThunk<void> => async (dispatch) => {
   dispatch(fetchBrokersAction.request());
   try {
-    const { brokers } = await getBrokers();
+    const { brokers } = await getBrokers(clusterId);
     dispatch(fetchBrokersAction.success(brokers));
   } catch (e) {
     dispatch(fetchBrokersAction.failure());

+ 0 - 9
frontend/src/theme/bulma_overrides.scss

@@ -1,13 +1,4 @@
 @import "../../node_modules/bulma/sass/utilities/_all.sass";
-
-$menu-item-color: $white;
-$menu-item-radius: 0;
-$menu-item-hover-color: $white;
-$menu-item-hover-background-color: transparent;
-$menu-item-active-color: $text-strong;
-$menu-item-active-background-color: $background;
-$menu-list-border-left: 1px solid $border-light;
-
 @import "../../node_modules/bulma/sass/base/_all.sass";
 @import "../../node_modules/bulma/sass/elements/_all.sass";
 @import "../../node_modules/bulma/sass/form/_all.sass";

+ 23 - 0
frontend/src/theme/index.scss

@@ -11,9 +11,32 @@
     sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
+  background: repeating-linear-gradient(
+    145deg,
+    rgba(0,0,0,.003),
+    rgba(0,0,0,.005) 5px,
+    rgba(0,0,0,0) 5px,
+    rgba(0,0,0,0) 10px
+  ),
+  repeating-linear-gradient(
+    -145deg,
+    rgba(0,0,0,.003),
+    rgba(0,0,0,.005) 5px,
+    rgba(0,0,0,0) 5px,
+    rgba(0,0,0,0) 10px
+  );
+  background-color: $light;
+
 }
 
 code {
   font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
     monospace;
 }
+
+.has-text-overflow-ellipsis {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}

+ 23 - 0
frontend/src/types/cluster.ts

@@ -0,0 +1,23 @@
+import { FetchStatus } from "types";
+
+export enum ClusterStatus {
+  Online = 'online',
+  Offline = 'offline',
+}
+
+export type ClusterId = string;
+
+export interface Cluster {
+  id: ClusterId;
+  name: string;
+  defaultCluster: boolean;
+  status: ClusterStatus;
+  brokerCount: number;
+  onlinePartitionCount: number;
+  topicCount: number;
+}
+
+export interface ClustersState {
+  fetchStatus: FetchStatus;
+  items: Cluster[];
+}

+ 6 - 1
frontend/src/types/index.ts

@@ -1,9 +1,13 @@
 import { ActionType } from 'typesafe-actions';
 import * as topicsActions from 'redux/reducers/topics/actions';
+import * as clustersActions from 'redux/reducers/clusters/actions';
 import { ThunkAction } from 'redux-thunk';
 import { TopicsState } from './topic';
 import { AnyAction } from 'redux';
+import { ClustersState } from './cluster';
+
 export * from './topic';
+export * from './cluster';
 
 export enum FetchStatus {
   notFetched = 'notFetched',
@@ -14,8 +18,9 @@ export enum FetchStatus {
 
 export interface RootState {
   topics: TopicsState;
+  clusters: ClustersState;
 }
 
-export type Action = ActionType<typeof topicsActions>;
+export type Action = ActionType<typeof topicsActions | typeof clustersActions>;
 
 export type PromiseThunk<T> = ThunkAction<Promise<T>, RootState, undefined, AnyAction>;