Add Cluster

This commit is contained in:
Oleg Shuralev 2019-12-25 09:33:17 +03:00
parent 15397cb459
commit 1468adbc45
No known key found for this signature in database
GPG key ID: 0459DF80E1A2FD1B
26 changed files with 350 additions and 169 deletions

View file

@ -1,92 +1,29 @@
$navbar-width: 64px; $header-height: 52px;
$navbar-width: 250px;
.Layout { .Layout {
width: 100%; &__header {
height: 100%; box-shadow: 0 0.46875rem 2.1875rem rgba(4,9,20,0.03),
display: flex; 0 0.9375rem 1.40625rem rgba(4,9,20,0.03),
overflow: hidden; 0 0.25rem 0.53125rem rgba(4,9,20,0.05),
position: relative; 0 0.125rem 0.1875rem rgba(4,9,20,0.03);
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;
} }
&__container { &__container {
flex: 1 1 auto; margin-top: $header-height;
overflow: auto; margin-left: $navbar-width;
z-index: 2;
} }
&__content { &__navbar {
margin-top: 52px; width: $navbar-width;
} display: flex;
flex-direction: column;
&__header { box-shadow: 7px 0 60px rgba(0,0,0,0.05);
position: fixed; position: fixed;
top: 0; top: $header-height;
background-color: #F7F7F7; left: 0;
width: 100%; bottom: 0;
z-index: 3; padding: 20px 20px;
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;
&--expanded {
margin-left: 0;
position: fixed;
top: 52px;
left: 0;
bottom: 0;
}
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,15 +5,23 @@ import {
} from 'redux/reducers/topics/thunks'; } from 'redux/reducers/topics/thunks';
import Topics from './Topics'; import Topics from './Topics';
import { getIsTopicListFetched } from 'redux/reducers/topics/selectors'; 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), isFetched: getIsTopicListFetched(state),
clusterId,
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
fetchTopicList, fetchTopicList: (clusterId: ClusterId) => fetchTopicList(clusterId),
fetchBrokers, fetchBrokers: (clusterId: ClusterId) => fetchBrokers(clusterId),
} }
export default connect(mapStateToProps, mapDispatchToProps)(Topics); export default connect(mapStateToProps, mapDispatchToProps)(Topics);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,14 +7,14 @@ import {
fetchTopicListAction, fetchTopicListAction,
fetchBrokersAction, fetchBrokersAction,
} from './actions'; } 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()); dispatch(fetchTopicListAction.request());
try { try {
const topics = await getTopics(); const topics = await getTopics(clusterId);
const detailedList = await Promise.all(topics.map((topic: TopicName): Promise<Topic> => getTopic(topic))); const detailedList = await Promise.all(topics.map((topic: TopicName): Promise<Topic> => getTopic(topic)));
dispatch(fetchTopicListAction.success(detailedList)); 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()); dispatch(fetchBrokersAction.request());
try { try {
const { brokers } = await getBrokers(); const { brokers } = await getBrokers(clusterId);
dispatch(fetchBrokersAction.success(brokers)); dispatch(fetchBrokersAction.success(brokers));
} catch (e) { } catch (e) {
dispatch(fetchBrokersAction.failure()); dispatch(fetchBrokersAction.failure());

View file

@ -1,13 +1,4 @@
@import "../../node_modules/bulma/sass/utilities/_all.sass"; @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/base/_all.sass";
@import "../../node_modules/bulma/sass/elements/_all.sass"; @import "../../node_modules/bulma/sass/elements/_all.sass";
@import "../../node_modules/bulma/sass/form/_all.sass"; @import "../../node_modules/bulma/sass/form/_all.sass";

View file

@ -11,9 +11,32 @@
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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 { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; monospace;
} }
.has-text-overflow-ellipsis {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

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

View file

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