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 {
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;
margin-top: $header-height;
margin-left: $navbar-width;
}
&__content {
margin-top: 52px;
}
&__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;
&--expanded {
margin-left: 0;
position: fixed;
top: 52px;
left: 0;
bottom: 0;
}
top: $header-height;
left: 0;
bottom: 0;
padding: 20px 20px;
}
}

View file

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

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 ConfigRow from './ConfigRow';
import Partition from './Partition';
import { NavLink } from 'react-router-dom';
const Details: React.FC<{ topic: Topic }> = ({
topic: {

View file

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

View file

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

View file

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

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 './clusters';

View file

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

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 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 topics from './topics/reducer';
import clusters from './clusters/reducer';
import { RootState } from 'types';
export default combineReducers<RootState>({
topics,
clusters,
});

View file

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

View file

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

View file

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

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