Merge pull request #2 from provectus/feature/topics_ui_react

Topics UI
This commit is contained in:
Oleg Shur 2020-01-27 18:32:27 +03:00 committed by GitHub
commit 7193af2b52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
90 changed files with 19015 additions and 1 deletions

2
.env Normal file
View file

@ -0,0 +1,2 @@
# Kafka REST API
REACT_APP_API_URL=http://localhost:3004

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# production
build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -1,2 +1,39 @@
# kafka-ui
# Kafka-UI
UI for Apache Kafka management
## Table of contents
- [Getting started](#getting-started)
- [Links](#links)
## Getting started
Install packages
```
npm install
```
Set correct URL to your API server in `.env`.
```
REACT_APP_API_URL=http://api.your-kafka-rest-api.com:3004
```
Start JSON Server if you prefer to use default full fake REST API.
```
npm run mock
```
Start application
```
npm start
```
## Links
* [JSON Server](https://github.com/typicode/json-server) - Fake REST API.
* [Bulma](https://bulma.io/documentation/) - free, open source CSS framework based on Flexbox
* [Create React App](https://github.com/facebook/create-react-app)

57
docker-compose.yaml Normal file
View file

@ -0,0 +1,57 @@
version: '3'
services:
zookeeper:
image: zookeeper:3.4.13
ports:
- 2181:2181
restart: always
kafka:
image: confluentinc/cp-kafka:5.3.1
ports:
- 9093:9093
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: INTERNAL://0.0.0.0:9092,PLAINTEXT://0.0.0.0:9093
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:9092,PLAINTEXT://localhost:9093
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
depends_on:
- zookeeper
restart: always
schema-registry:
image: confluentinc/cp-schema-registry:5.3.1
hostname: schema-registry
ports:
- "8081:8081"
environment:
SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: PLAINTEXT://kafka:9092
SCHEMA_REGISTRY_HOST_NAME: schema-registry
SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081
depends_on:
- zookeeper
- kafka
rest:
image: confluentinc/cp-kafka-rest:5.3.1
hostname: rest-proxy
ports:
- "8082:8082"
environment:
KAFKA_REST_LISTENERS: http://0.0.0.0:8082/
KAFKA_REST_SCHEMA_REGISTRY_URL: http://schema-registry:8081/
KAFKA_REST_HOST_NAME: rest-proxy
KAFKA_REST_BOOTSTRAP_SERVERS: PLAINTEXT://kafka:9092
KAFKA_REST_ACCESS_CONTROL_ALLOW_ORIGIN: "*"
KAFKA_REST_ACCESS_CONTROL_ALLOW_METHODS: "GET,POST,PUT,DELETE,OPTIONS,HEAD"
depends_on:
- zookeeper
- kafka
- schema-registry

44
mock/index.js Normal file
View file

@ -0,0 +1,44 @@
const jsonServer = require('json-server');
const _ = require('lodash');
const clusters = require('./payload/clusters.json');
const brokers = require('./payload/brokers.json');
const brokerMetrics = require('./payload/brokerMetrics.json');
const topics = require('./payload/topics.json');
const topicDetails = require('./payload/topicDetails.json');
const topicConfigs = require('./payload/topicConfigs.json');
const db = {
clusters,
brokers,
brokerMetrics: brokerMetrics.map(({ clusterId, ...rest }) => ({ ...rest, id: clusterId })),
topics: topics.map((topic) => ({ ...topic, id: topic.name })),
topicDetails,
topicConfigs,
}
const server = jsonServer.create();
const router = jsonServer.router(db);
const middlewares = jsonServer.defaults();
const PORT = 3004;
const DELAY = 0;
server.use(middlewares);
server.use((_req, _res, next) => {
setTimeout(next, DELAY);
});
server.use(
jsonServer.rewriter({
'/*': '/$1',
'/clusters/:clusterId/metrics/broker': '/brokerMetrics/:clusterId',
'/clusters/:clusterId/topics/:id': '/topicDetails',
'/clusters/:clusterId/topics/:id/config': '/topicDetails',
'/clusters/:clusterId/topics/:id/config': '/topicConfigs',
})
);
server.use(router);
server.listen(PORT, () => {
console.log('JSON Server is running');
});

View file

@ -0,0 +1,42 @@
[
{
"clusterId": "wrYGf-csNgiGdK7B_ADF7Z",
"bytesInPerSec": 8027,
"brokerCount": 1,
"zooKeeperStatus": 1,
"activeControllers": 1,
"uncleanLeaderElectionCount": 0,
"networkPoolUsage": 0.001970896739179595,
"requestPoolUsage": 0.00730438980248805,
"onlinePartitionCount": 19,
"underReplicatedPartitionCount": 9,
"offlinePartitionCount": 3,
"diskUsage": [
{
"brokerId": 1,
"segmentSize": 479900675
}
],
"diskUsageDistribution": "even"
},
{
"clusterId": "dMMQx-WRh77BKYas_g2ZTz",
"bytesInPerSec": 8194,
"brokerCount": 1,
"zooKeeperStatus": 1,
"activeControllers": 1,
"uncleanLeaderElectionCount": 0,
"networkPoolUsage": 0.004401004145400575,
"requestPoolUsage": 0.004089519725388984,
"onlinePartitionCount": 70,
"underReplicatedPartitionCount": 1,
"offlinePartitionCount": 2,
"diskUsage": [
{
"brokerId": 1,
"segmentSize": 968226532
}
],
"diskUsageDistribution": "even"
}
]

18
mock/payload/brokers.json Normal file
View file

@ -0,0 +1,18 @@
[
{
"brokerId": 1,
"clusterId": "wrYGf-csNgiGdK7B_ADF7Z",
"bytesInPerSec": 1234,
"bytesOutPerSec": 3567,
"segmentSize": 912360707,
"partitionReplicas": 20
},
{
"brokerId": 2,
"clusterId": "dMMQx-WRh77BKYas_g2ZTz",
"bytesInPerSec": 9194,
"bytesOutPerSec": 7924,
"segmentSize": 840060707,
"partitionReplicas": 50
}
]

View file

@ -0,0 +1,24 @@
[
{
"id": "wrYGf-csNgiGdK7B_ADF7Z",
"name": "fake.cluster",
"defaultCluster": true,
"status": "online",
"brokerCount": 1,
"onlinePartitionCount": 20,
"topicCount": 2,
"bytesInPerSec": 3201,
"bytesOutPerSec": 4320
},
{
"id": "dMMQx-WRh77BKYas_g2ZTz",
"name": "kafka-ui.cluster",
"defaultCluster": false,
"status": "offline",
"brokerCount": 1,
"onlinePartitionCount": 20,
"topicCount": 2,
"bytesInPerSec": 8341,
"bytesOutPerSec": 10320
}
]

View file

@ -0,0 +1,132 @@
[
{
"name": "compression.type",
"value": "producer",
"defaultValue": "producer"
},
{
"name": "leader.replication.throttled.replicas",
"value": "",
"defaultValue": ""
},
{
"name": "message.downconversion.enable",
"value": "false",
"defaultValue": "true"
},
{
"name": "min.insync.replicas",
"value": "1",
"defaultValue": "1"
},
{
"name": "segment.jitter.ms",
"value": "0",
"defaultValue": "0"
},
{
"name": "cleanup.policy",
"value": "compact",
"defaultValue": "delete"
},
{
"name": "flush.ms",
"value": "9223372036854775807",
"defaultValue": "9223372036854775807"
},
{
"name": "follower.replication.throttled.replicas",
"value": "",
"defaultValue": ""
},
{
"name": "segment.bytes",
"value": "1073741824",
"defaultValue": "1073741824"
},
{
"name": "retention.ms",
"value": "43200000",
"defaultValue": "43200000"
},
{
"name": "flush.messages",
"value": "9223372036854775807",
"defaultValue": "9223372036854775807"
},
{
"name": "message.format.version",
"value": "2.3-IV1",
"defaultValue": "2.3-IV1"
},
{
"name": "file.delete.delay.ms",
"value": "60000",
"defaultValue": "60000"
},
{
"name": "max.compaction.lag.ms",
"value": "9223372036854775807",
"defaultValue": "9223372036854775807"
},
{
"name": "max.message.bytes",
"value": "1000012",
"defaultValue": "1000012"
},
{
"name": "min.compaction.lag.ms",
"value": "0",
"defaultValue": "0"
},
{
"name": "message.timestamp.type",
"value": "CreateTime",
"defaultValue": "CreateTime"
},
{
"name": "preallocate",
"value": "false",
"defaultValue": "false"
},
{
"name": "min.cleanable.dirty.ratio",
"value": "0.5",
"defaultValue": "0.5"
},
{
"name": "index.interval.bytes",
"value": "4096",
"defaultValue": "4096"
},
{
"name": "unclean.leader.election.enable",
"value": "true",
"defaultValue": "true"
},
{
"name": "retention.bytes",
"value": "-1",
"defaultValue": "-1"
},
{
"name": "delete.retention.ms",
"value": "86400000",
"defaultValue": "86400000"
},
{
"name": "segment.ms",
"value": "604800000",
"defaultValue": "604800000"
},
{
"name": "message.timestamp.difference.max.ms",
"value": "9223372036854775807",
"defaultValue": "9223372036854775807"
},
{
"name": "segment.index.bytes",
"value": "10485760",
"defaultValue": "10485760"
}
]

View file

@ -0,0 +1,10 @@
{
"partitionCount": 25,
"replicationFactor": 1,
"replicas": 25,
"inSyncReplicas": 25,
"bytesInPerSec": 0,
"segmentSize": 0,
"segmentCount": 25,
"underReplicatedPartitions": 0
}

227
mock/payload/topics.json Normal file
View file

@ -0,0 +1,227 @@
[
{
"clusterId": "wrYGf-csNgiGdK7B_ADF7Z",
"name": "docker-connect-status",
"internal": true,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": false
}
]
},
{
"partition": 1,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 2,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 3,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 4,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
},
{
"clusterId": "wrYGf-csNgiGdK7B_ADF7Z",
"name": "dsadsda",
"internal": false,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
},
{
"clusterId": "wrYGf-csNgiGdK7B_ADF7Z",
"name": "my-topic",
"internal": false,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
},
{
"clusterId": "wrYGf-csNgiGdK7B_ADF7Z",
"name": "docker-connect-offsets",
"internal": false,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 1,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 2,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 3,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 4,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
},
{
"partition": 5,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
},
{
"clusterId": "dMMQx-WRh77BKYas_g2ZTz",
"name": "_schemas",
"internal": false,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
},
{
"clusterId": "dMMQx-WRh77BKYas_g2ZTz",
"name": "docker-connect-configs",
"internal": false,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
},
{
"clusterId": "dMMQx-WRh77BKYas_g2ZTz",
"name": "_kafka-ui-test-topic-monitoring-message-rekey-store",
"internal": false,
"partitions": [
{
"partition": 0,
"leader": 1,
"replicas": [
{
"broker": 1,
"leader": true,
"inSync": true
}
]
}
]
}
]

15701
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

59
package.json Normal file
View file

@ -0,0 +1,59 @@
{
"name": "kafka-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/classnames": "^2.2.9",
"@types/jest": "^24.0.25",
"@types/lodash": "^4.14.149",
"@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",
"@types/redux": "^3.6.0",
"@types/redux-thunk": "^2.1.0",
"bulma": "^0.8.0",
"bulma-switch": "^2.0.0",
"classnames": "^2.2.6",
"json-server": "^0.15.1",
"lodash": "^4.17.15",
"node-sass": "^4.13.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hook-form": "^4.5.5",
"react-redux": "^7.1.3",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.0",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"typesafe-actions": "^5.1.0",
"typescript": "~3.7.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"mock": "node ./mock/index.js"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

16
public/index.html Normal file
View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<script defer src="https://use.fontawesome.com/releases/v5.12.0/js/all.js"></script>
<title>Kafka UI</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

15
public/manifest.json Normal file
View file

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *

30
src/components/App.scss Normal file
View file

@ -0,0 +1,30 @@
$header-height: 52px;
$navbar-width: 250px;
.Layout {
&__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);
z-index: 31;
}
&__container {
margin-top: $header-height;
margin-left: $navbar-width;
}
&__navbar {
width: $navbar-width;
display: flex;
flex-direction: column;
box-shadow: 7px 0 60px rgba(0,0,0,0.05);
position: fixed;
top: $header-height;
left: 0;
bottom: 0;
padding: 20px 20px;
}
}

53
src/components/App.tsx Normal file
View file

@ -0,0 +1,53 @@
import React from 'react';
import {
Switch,
Route,
Redirect,
} from 'react-router-dom';
import './App.scss';
import BrokersContainer from './Brokers/BrokersContainer';
import TopicsContainer from './Topics/TopicsContainer';
import NavConatiner from './Nav/NavConatiner';
import PageLoader from './common/PageLoader/PageLoader';
import Dashboard from './Dashboard/Dashboard';
interface AppProps {
isClusterListFetched: boolean;
fetchClustersList: () => void;
}
const App: React.FC<AppProps> = ({
isClusterListFetched,
fetchClustersList,
}) => {
React.useEffect(() => { fetchClustersList() }, [fetchClustersList]);
return (
<div className="Layout">
<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>
</nav>
<main className="Layout__container">
<NavConatiner className="Layout__navbar" />
{isClusterListFetched ? (
<Switch>
<Route exact path="/" component={Dashboard} />
<Route exact path="/clusters" component={Dashboard} />
<Route path="/clusters/:clusterId/topics" component={TopicsContainer} />
<Route path="/clusters/:clusterId/brokers" component={BrokersContainer} />
<Redirect from="/clusters/:clusterId" to="/clusters/:clusterId/brokers" />
</Switch>
) : (
<PageLoader />
)}
</main>
</div>
);
}
export default App;

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 'lib/interfaces';
const mapStateToProps = (state: RootState) => ({
isClusterListFetched: getIsClusterListFetched(state),
});
const mapDispatchToProps = {
fetchClustersList,
}
export default connect(mapStateToProps, mapDispatchToProps)(App);

View file

@ -0,0 +1,121 @@
import React from 'react';
import { ClusterId, BrokerMetrics, ZooKeeperStatus } from 'lib/interfaces';
import useInterval from 'lib/hooks/useInterval';
import formatBytes from 'lib/utils/formatBytes';
import cx from 'classnames';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import Indicator from 'components/common/Dashboard/Indicator';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
interface Props extends BrokerMetrics {
clusterId: string;
isFetched: boolean;
minDiskUsage: number;
maxDiskUsage: number;
fetchBrokers: (clusterId: ClusterId) => void;
fetchBrokerMetrics: (clusterId: ClusterId) => void;
}
const Topics: React.FC<Props> = ({
clusterId,
isFetched,
brokerCount,
activeControllers,
zooKeeperStatus,
onlinePartitionCount,
offlinePartitionCount,
underReplicatedPartitionCount,
diskUsageDistribution,
minDiskUsage,
maxDiskUsage,
networkPoolUsage,
requestPoolUsage,
fetchBrokers,
fetchBrokerMetrics,
}) => {
React.useEffect(
() => {
fetchBrokers(clusterId);
fetchBrokerMetrics(clusterId);
},
[fetchBrokers, fetchBrokerMetrics, clusterId],
);
useInterval(() => { fetchBrokerMetrics(clusterId); }, 5000);
const [minDiskUsageValue, minDiskUsageSize] = formatBytes(minDiskUsage);
const [maxDiskUsageValue, maxDiskUsageSize] = formatBytes(maxDiskUsage);
const zkOnline = zooKeeperStatus === ZooKeeperStatus.online;
return (
<div className="section">
<Breadcrumb>Brokers overview</Breadcrumb>
<MetricsWrapper title="Uptime">
<Indicator label="Total Brokers">
{brokerCount}
</Indicator>
<Indicator label="Active Controllers">
{activeControllers}
</Indicator>
<Indicator label="Zookeeper Status">
<span className={cx('tag', zkOnline ? 'is-primary' : 'is-danger')}>
{zkOnline ? 'Online' : 'Offline'}
</span>
</Indicator>
</MetricsWrapper>
<MetricsWrapper title="Partitions">
<Indicator label="Online">
<span className={cx({'has-text-danger': offlinePartitionCount !== 0})}>
{onlinePartitionCount}
</span>
<span className="subtitle has-text-weight-light"> of {onlinePartitionCount + offlinePartitionCount}</span>
</Indicator>
<Indicator label="URP" title="Under replicated partitions">
{underReplicatedPartitionCount}
</Indicator>
<Indicator label="In Sync Replicas">
<span className="has-text-grey-lighter">
Soon
</span>
</Indicator>
<Indicator label="Out of Sync Replicas">
<span className="has-text-grey-lighter">
Soon
</span>
</Indicator>
</MetricsWrapper>
<MetricsWrapper title="Disk">
<Indicator label="Max usage">
{maxDiskUsageValue}
<span className="subtitle has-text-weight-light"> {maxDiskUsageSize}</span>
</Indicator>
<Indicator label="Min usage">
{minDiskUsageValue}
<span className="subtitle has-text-weight-light"> {minDiskUsageSize}</span>
</Indicator>
<Indicator label="Distribution">
<span className="is-capitalized">
{diskUsageDistribution}
</span>
</Indicator>
</MetricsWrapper>
<MetricsWrapper title="System">
<Indicator label="Network pool usage">
{Math.round(networkPoolUsage * 10000) / 100}
<span className="subtitle has-text-weight-light">%</span>
</Indicator>
<Indicator label="Request pool usage">
{Math.round(requestPoolUsage * 10000) / 100}
<span className="subtitle has-text-weight-light">%</span>
</Indicator>
</MetricsWrapper>
</div>
);
}
export default Topics;

View file

@ -0,0 +1,38 @@
import { connect } from 'react-redux';
import {
fetchBrokers,
fetchBrokerMetrics,
} from 'redux/reducers/brokers/thunks';
import Brokers from './Brokers';
import * as brokerSelectors from 'redux/reducers/brokers/selectors';
import { RootState, ClusterId } from 'lib/interfaces';
import { RouteComponentProps } from 'react-router-dom';
interface RouteProps {
clusterId: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { clusterId } }}: OwnProps) => ({
isFetched: brokerSelectors.getIsBrokerListFetched(state),
clusterId,
brokerCount: brokerSelectors.getBrokerCount(state),
zooKeeperStatus: brokerSelectors.getZooKeeperStatus(state),
activeControllers: brokerSelectors.getActiveControllers(state),
networkPoolUsage: brokerSelectors.getNetworkPoolUsage(state),
requestPoolUsage: brokerSelectors.getRequestPoolUsage(state),
onlinePartitionCount: brokerSelectors.getOnlinePartitionCount(state),
offlinePartitionCount: brokerSelectors.getOfflinePartitionCount(state),
underReplicatedPartitionCount: brokerSelectors.getUnderReplicatedPartitionCount(state),
diskUsageDistribution: brokerSelectors.getDiskUsageDistribution(state),
minDiskUsage: brokerSelectors.getMinDiskUsage(state),
maxDiskUsage: brokerSelectors.getMaxDiskUsage(state),
});
const mapDispatchToProps = {
fetchBrokers: (clusterId: ClusterId) => fetchBrokers(clusterId),
fetchBrokerMetrics: (clusterId: ClusterId) => fetchBrokerMetrics(clusterId),
}
export default connect(mapStateToProps, mapDispatchToProps)(Brokers);

View file

@ -0,0 +1,59 @@
import React from 'react';
import { Cluster, ClusterStatus } from 'lib/interfaces';
import formatBytes from 'lib/utils/formatBytes';
import { NavLink } from 'react-router-dom';
import { clusterBrokersPath } from 'lib/paths';
const ClusterWidget: React.FC<Cluster> = ({
id,
name,
status,
topicCount,
brokerCount,
bytesInPerSec,
bytesOutPerSec,
onlinePartitionCount,
}) => (
<NavLink to={clusterBrokersPath(id)} className="column is-full-modile is-6">
<div className="box is-hoverable">
<div
className="title is-6 has-text-overflow-ellipsis"
title={name}
>
<div
className={`tag has-margin-right ${status === ClusterStatus.Online ? 'is-primary' : 'is-danger'}`}
>
{status}
</div>
{name}
</div>
<table className="table is-fullwidth">
<tbody>
<tr>
<th>Brokers</th>
<td>{brokerCount}</td>
</tr>
<tr>
<th>Partitions</th>
<td>{onlinePartitionCount}</td>
</tr>
<tr>
<th>Topics</th>
<td>{topicCount}</td>
</tr>
<tr>
<th>Production</th>
<td>{formatBytes(bytesInPerSec)}</td>
</tr>
<tr>
<th>Consumption</th>
<td>{formatBytes(bytesOutPerSec)}</td>
</tr>
</tbody>
</table>
</div>
</NavLink>
);
export default ClusterWidget;

View file

@ -0,0 +1,77 @@
import React from 'react';
import { chunk } from 'lodash';
import { Cluster } from 'lib/interfaces';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import Indicator from 'components/common/Dashboard/Indicator';
import ClusterWidget from './ClusterWidget';
interface Props {
clusters: Cluster[];
onlineClusters: Cluster[];
offlineClusters: Cluster[];
}
const ClustersWidget: React.FC<Props> = ({
clusters,
onlineClusters,
offlineClusters,
}) => {
const [showOfflineOnly, setShowOfflineOnly] = React.useState<boolean>(false);
const clusterList: Array<Cluster[]> = React.useMemo(() => {
let list = clusters;
if (showOfflineOnly) {
list = offlineClusters;
}
return chunk(list, 2);
},
[clusters, offlineClusters, showOfflineOnly],
);
const handleSwitch = () => setShowOfflineOnly(!showOfflineOnly);
return (
<div>
<h5 className="title is-5">
Clusters
</h5>
<MetricsWrapper>
<Indicator label="Online Clusters" >
<span className="tag is-primary">
{onlineClusters.length}
</span>
</Indicator>
<Indicator label="Offline Clusters">
<span className="tag is-danger">
{offlineClusters.length}
</span>
</Indicator>
<Indicator label="Hide online clusters">
<input
type="checkbox"
className="switch is-rounded"
name="switchRoundedDefault"
id="switchRoundedDefault"
checked={showOfflineOnly}
onChange={handleSwitch}
/>
<label htmlFor="switchRoundedDefault">
</label>
</Indicator>
</MetricsWrapper>
{clusterList.map((chunk, idx) => (
<div className="columns" key={`dashboard-cluster-list-row-key-${idx}`}>
{chunk.map((cluster, idx) => (
<ClusterWidget {...cluster} key={`dashboard-cluster-list-item-key-${idx}`}/>
))}
</div>
))}
</div>
)
};
export default ClustersWidget;

View file

@ -0,0 +1,16 @@
import { connect } from 'react-redux';
import ClustersWidget from './ClustersWidget';
import {
getClusterList,
getOnlineClusters,
getOfflineClusters,
} from 'redux/reducers/clusters/selectors';
import { RootState } from 'lib/interfaces';
const mapStateToProps = (state: RootState) => ({
clusters: getClusterList(state),
onlineClusters: getOnlineClusters(state),
offlineClusters: getOfflineClusters(state),
});
export default connect(mapStateToProps)(ClustersWidget);

View file

@ -0,0 +1,17 @@
import React from 'react';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import ClustersWidgetContainer from './ClustersWidget/ClustersWidgetContainer';
const Dashboard: React.FC = () => (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb>Dashboard</Breadcrumb>
</div>
</div>
<ClustersWidgetContainer />
</div>
);
export default Dashboard;

View file

@ -0,0 +1,46 @@
import React, { CSSProperties } from 'react';
import { Cluster } from 'lib/interfaces';
import { NavLink } from 'react-router-dom';
import { clusterBrokersPath, clusterTopicsPath } from 'lib/paths';
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={clusterBrokersPath(id)} title={name} className="has-text-overflow-ellipsis">
{defaultCluster && <DefaultIcon />}
{name}
</NavLink>
<ul>
<NavLink to={clusterBrokersPath(id)} activeClassName="is-active" title="Brokers">
Brokers
</NavLink>
<NavLink to={clusterTopicsPath(id)} activeClassName="is-active" title="Topics">
Topics
</NavLink>
</ul>
</li>
</ul>
);
export default ClusterMenu;

View file

@ -0,0 +1,38 @@
import React from 'react';
import { Cluster } from 'lib/interfaces';
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, index) => <ClusterMenu {...cluster} key={`cluster-list-item-key-${index}`}/>)}
</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 'lib/interfaces';
const mapStateToProps = (state: RootState) => ({
isClusterListFetched: getIsClusterListFetched(state),
clusters: getClusterList(state),
});
export default connect(mapStateToProps)(Nav);

View file

@ -0,0 +1,69 @@
import React from 'react';
import { ClusterId, Topic, TopicDetails, TopicName } from 'lib/interfaces';
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;
}
const Details: React.FC<Props> = ({
clusterId,
topicName,
}) => {
return (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb links={[
{ href: clusterTopicsPath(clusterId), label: 'All Topics' },
]}>
{topicName}
</Breadcrumb>
</div>
</div>
<div className="box">
<nav className="navbar" role="navigation">
<NavLink
exact
to={clusterTopicPath(clusterId, topicName)}
className="navbar-item is-tab"
activeClassName="is-active is-primary"
>
Overview
</NavLink>
<NavLink
exact
to={clusterTopicMessagesPath(clusterId, topicName)}
className="navbar-item is-tab"
activeClassName="is-active"
>
Messages
</NavLink>
<NavLink
exact
to={clusterTopicSettingsPath(clusterId, topicName)}
className="navbar-item is-tab"
activeClassName="is-active"
>
Settings
</NavLink>
</nav>
<br />
<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>
);
}
export default Details;

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import Details from './Details';
import { RootState } from 'lib/interfaces';
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)(Details)
);

View file

@ -0,0 +1,20 @@
import React from 'react';
import { ClusterId, TopicName } from 'lib/interfaces';
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 'lib/interfaces';
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,79 @@
import React from 'react';
import { ClusterId, Topic, TopicDetails, TopicName } from 'lib/interfaces';
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,
internal,
replicationFactor,
fetchTopicDetails,
}) => {
React.useEffect(
() => { fetchTopicDetails(clusterId, topicName); },
[fetchTopicDetails, clusterId, topicName],
);
if (!isFetched) {
return null;
}
return (
<>
<MetricsWrapper>
<Indicator label="Partitions">
{partitionCount}
</Indicator>
<Indicator label="Replication Factor">
{replicationFactor}
</Indicator>
<Indicator label="URP" title="Under replicated partitions">
{underReplicatedPartitions}
</Indicator>
<Indicator label="In sync replicas">
{inSyncReplicas}
<span className="subtitle has-text-weight-light"> of {replicas}</span>
</Indicator>
<Indicator label="Type">
<span className="tag is-primary">
{internal ? 'Internal' : 'External'}
</span>
</Indicator>
</MetricsWrapper>
<div className="box">
<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 key={`partition-list-item-key-${partition}`}>
<td>{partition}</td>
<td>{leader}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
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 'lib/interfaces';
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

@ -0,0 +1,71 @@
import React from 'react';
import { ClusterId, TopicName, TopicConfig } from 'lib/interfaces';
interface Props {
clusterId: ClusterId;
topicName: TopicName;
config?: TopicConfig[];
isFetched: boolean;
fetchTopicConfig: (clusterId: ClusterId, topicName: TopicName) => void;
}
const ConfigListItem: React.FC<TopicConfig> = ({
name,
value,
defaultValue,
}) => {
const hasCustomValue = value !== defaultValue;
return (
<tr>
<td className={hasCustomValue ? 'has-text-weight-bold' : ''}>
{name}
</td>
<td className={hasCustomValue ? 'has-text-weight-bold' : ''}>
{value}
</td>
<td
className="has-text-grey"
title="Default Value"
>
{hasCustomValue && defaultValue}
</td>
</tr>
)
}
const Sertings: React.FC<Props> = ({
clusterId,
topicName,
isFetched,
fetchTopicConfig,
config,
}) => {
React.useEffect(
() => { fetchTopicConfig(clusterId, topicName); },
[fetchTopicConfig, clusterId, topicName],
);
if (!isFetched || !config) {
return (null);
}
return (
<div className="box">
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Default Value</th>
</tr>
</thead>
<tbody>
{config.map((item, index) => <ConfigListItem key={`config-list-item-key-${index}`} {...item} />)}
</tbody>
</table>
</div>
);
}
export default Sertings;

View file

@ -0,0 +1,34 @@
import { connect } from 'react-redux';
import { RootState, ClusterId, TopicName } from 'lib/interfaces';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import {
fetchTopicConfig,
} from 'redux/reducers/topics/thunks';
import Settings from './Settings';
import {
getTopicConfig,
getTopicConfigFetched,
} from 'redux/reducers/topics/selectors';
interface RouteProps {
clusterId: string;
topicName: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { topicName, clusterId } } }: OwnProps) => ({
clusterId,
topicName,
config: getTopicConfig(state, topicName),
isFetched: getTopicConfigFetched(state),
});
const mapDispatchToProps = {
fetchTopicConfig: (clusterId: ClusterId, topicName: TopicName) => fetchTopicConfig(clusterId, topicName),
}
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Settings)
);

View file

@ -0,0 +1,80 @@
import React from 'react';
import { TopicWithDetailedInfo, ClusterId } from 'lib/interfaces';
import ListItem from './ListItem';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { NavLink } from 'react-router-dom';
import { clusterTopicNewPath } from 'lib/paths';
interface Props {
clusterId: ClusterId;
topics: (TopicWithDetailedInfo)[];
externalTopics: (TopicWithDetailedInfo)[];
}
const List: React.FC<Props> = ({
clusterId,
topics,
externalTopics,
}) => {
const [showInternal, setShowInternal] = React.useState<boolean>(true);
const handleSwitch = () => setShowInternal(!showInternal);
const items = showInternal ? topics : externalTopics;
return (
<div className="section">
<Breadcrumb>All Topics</Breadcrumb>
<div className="box">
<div className="level">
<div className="level-item level-left">
<div className="field">
<input
id="switchRoundedDefault"
type="checkbox"
name="switchRoundedDefault"
className="switch is-rounded"
checked={showInternal}
onChange={handleSwitch}
/>
<label htmlFor="switchRoundedDefault">
Show Internal Topics
</label>
</div>
</div>
<div className="level-item level-right">
<NavLink
className="button is-primary"
to={clusterTopicNewPath(clusterId)}
>
Add a Topic
</NavLink>
</div>
</div>
</div>
<div className="box">
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Topic Name</th>
<th>Total Partitions</th>
<th>Out of sync replicas</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{items.map((topic, index) => (
<ListItem
key={`topic-list-item-key-${index}`}
{...topic}
/>
))}
</tbody>
</table>
</div>
</div>
);
}
export default List;

View file

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import { RootState } from 'lib/interfaces';
import { getTopicList, getExternalTopicList } from 'redux/reducers/topics/selectors';
import List from './List';
import { withRouter, RouteComponentProps } from 'react-router-dom';
interface RouteProps {
clusterId: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { clusterId } } }: OwnProps) => ({
clusterId,
topics: getTopicList(state),
externalTopics: getExternalTopicList(state),
});
export default withRouter(
connect(mapStateToProps)(List)
);

View file

@ -0,0 +1,40 @@
import React from 'react';
import cx from 'classnames';
import { NavLink } from 'react-router-dom';
import { TopicWithDetailedInfo } from 'lib/interfaces';
const ListItem: React.FC<TopicWithDetailedInfo> = ({
name,
internal,
partitions,
}) => {
const outOfSyncReplicas = React.useMemo(() => {
if (partitions === undefined || partitions.length === 0) {
return 0;
}
return partitions.reduce((memo: number, { replicas }) => {
const outOfSync = replicas.filter(({ inSync }) => !inSync)
return memo + outOfSync.length;
}, 0);
}, [partitions])
return (
<tr>
<td>
<NavLink exact to={`topics/${name}`} activeClassName="is-active" className="title is-6">
{name}
</NavLink>
</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>
);
}
export default ListItem;

View file

@ -0,0 +1,247 @@
import React from 'react';
import { ClusterId, CleanupPolicy, TopicFormData, TopicName } from 'lib/interfaces';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { clusterTopicsPath } from 'lib/paths';
import { useForm, ErrorMessage } from 'react-hook-form';
import {
TOPIC_NAME_VALIDATION_PATTERN,
MILLISECONDS_IN_DAY,
BYTES_IN_GB,
} from 'lib/constants';
interface Props {
clusterId: ClusterId;
isTopicCreated: boolean;
createTopic: (clusterId: ClusterId, form: TopicFormData) => void;
redirectToTopicPath: (clusterId: ClusterId, topicName: TopicName) => void;
}
const New: React.FC<Props> = ({
clusterId,
isTopicCreated,
createTopic,
redirectToTopicPath,
}) => {
const { register, handleSubmit, errors, getValues } = useForm<TopicFormData>();
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
React.useEffect(
() => {
if (isSubmitting && isTopicCreated) {
const { name } = getValues();
redirectToTopicPath(clusterId, name);
}
},
[isSubmitting, isTopicCreated, redirectToTopicPath, clusterId, getValues],
);
const onSubmit = async (data: TopicFormData) => {
setIsSubmitting(true);
createTopic(clusterId, data);
}
return (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb links={[
{ href: clusterTopicsPath(clusterId), label: 'All Topics' },
]}>
New Topic
</Breadcrumb>
</div>
</div>
<div className="box">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="columns">
<div className="column is-three-quarters">
<label className="label">
Topic Name *
</label>
<input
className="input"
placeholder="Topic Name"
ref={register({
required: 'Topic Name is required.',
pattern: {
value: TOPIC_NAME_VALIDATION_PATTERN,
message: 'Only alphanumeric, _, -, and . allowed',
},
})}
name="name"
autoComplete="off"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="name" />
</p>
</div>
<div className="column">
<label className="label">
Number of partitions *
</label>
<input
className="input"
type="number"
placeholder="Number of partitions"
defaultValue="1"
ref={register({ required: 'Number of partitions is required.' })}
name="partitions"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="partitions" />
</p>
</div>
</div>
<div className="columns">
<div className="column">
<label className="label">
Replication Factor *
</label>
<input
className="input"
type="number"
placeholder="Replication Factor"
defaultValue="1"
ref={register({ required: 'Replication Factor is required.' })}
name="replicationFactor"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="replicationFactor" />
</p>
</div>
<div className="column">
<label className="label">
Min In Sync Replicas *
</label>
<input
className="input"
type="number"
placeholder="Replication Factor"
defaultValue="1"
ref={register({ required: 'Min In Sync Replicas is required.' })}
name="minInSyncReplicas"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="minInSyncReplicas" />
</p>
</div>
</div>
<div className="columns">
<div className="column is-one-third">
<label className="label">
Cleanup policy
</label>
<div className="select is-block">
<select
defaultValue={CleanupPolicy.Delete}
name="cleanupPolicy"
ref={register}
disabled={isSubmitting}
>
<option value={CleanupPolicy.Delete}>
Delete
</option>
<option value={CleanupPolicy.Compact}>
Compact
</option>
</select>
</div>
</div>
<div className="column is-one-third">
<label className="label">
Time to retain data
</label>
<div className="select is-block">
<select
defaultValue={MILLISECONDS_IN_DAY * 7}
name="retentionMs"
ref={register}
disabled={isSubmitting}
>
<option value={MILLISECONDS_IN_DAY / 2 }>
12 hours
</option>
<option value={MILLISECONDS_IN_DAY}>
1 day
</option>
<option value={MILLISECONDS_IN_DAY * 2}>
2 days
</option>
<option value={MILLISECONDS_IN_DAY * 7}>
1 week
</option>
<option value={MILLISECONDS_IN_DAY * 7 * 4}>
4 weeks
</option>
</select>
</div>
</div>
<div className="column is-one-third">
<label className="label">
Max size on disk in GB
</label>
<div className="select is-block">
<select
defaultValue={-1}
name="retentionBytes"
ref={register}
disabled={isSubmitting}
>
<option value={-1}>
Not Set
</option>
<option value={BYTES_IN_GB}>
1 GB
</option>
<option value={BYTES_IN_GB * 10}>
10 GB
</option>
<option value={BYTES_IN_GB * 20}>
20 GB
</option>
<option value={BYTES_IN_GB * 50}>
50 GB
</option>
</select>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<label className="label">
Maximum message size in bytes *
</label>
<input
className="input"
type="number"
defaultValue="1000012"
ref={register({ required: 'Maximum message size in bytes is required' })}
name="maxMessageBytes"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="maxMessageBytes" />
</p>
</div>
</div>
<input type="submit" className="button is-primary" disabled={isSubmitting} />
</form>
</div>
</div>
);
}
export default New;

View file

@ -0,0 +1,33 @@
import { connect } from 'react-redux';
import { RootState, ClusterId, TopicFormData, TopicName, Action } from 'lib/interfaces';
import New from './New';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { createTopic } from 'redux/reducers/topics/thunks';
import { getTopicCreated } from 'redux/reducers/topics/selectors';
import { clusterTopicPath } from 'lib/paths';
import { ThunkDispatch } from 'redux-thunk';
interface RouteProps {
clusterId: string;
}
interface OwnProps extends RouteComponentProps<RouteProps> { }
const mapStateToProps = (state: RootState, { match: { params: { clusterId } } }: OwnProps) => ({
clusterId,
isTopicCreated: getTopicCreated(state),
});
const mapDispatchToProps = (dispatch: ThunkDispatch<RootState, undefined, Action>, { history }: OwnProps) => ({
createTopic: (clusterId: ClusterId, form: TopicFormData) => {
dispatch(createTopic(clusterId, form))
},
redirectToTopicPath: (clusterId: ClusterId, topicName: TopicName) => {
history.push(clusterTopicPath(clusterId, topicName));
}
});
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(New)
);

View file

@ -0,0 +1,39 @@
import React from 'react';
import { ClusterId } from 'lib/interfaces';
import {
Switch,
Route,
} from 'react-router-dom';
import ListContainer from './List/ListContainer';
import DetailsContainer from './Details/DetailsContainer';
import PageLoader from 'components/common/PageLoader/PageLoader';
import NewContainer from './New/NewContainer';
interface Props {
clusterId: string;
isFetched: boolean;
fetchBrokers: (clusterId: ClusterId) => void;
fetchTopicList: (clusterId: ClusterId) => void;
}
const Topics: React.FC<Props> = ({
clusterId,
isFetched,
fetchTopicList,
}) => {
React.useEffect(() => { fetchTopicList(clusterId); }, [fetchTopicList, clusterId]);
if (isFetched) {
return (
<Switch>
<Route exact path="/clusters/:clusterId/topics" component={ListContainer} />
<Route exact path="/clusters/:clusterId/topics/new" component={NewContainer} />
<Route path="/clusters/:clusterId/topics/:topicName" component={DetailsContainer} />
</Switch>
);
}
return (<PageLoader />);
}
export default Topics;

View file

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

View file

@ -0,0 +1,34 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
interface Link {
label: string;
href: string;
}
interface Props {
links?: Link[];
}
const Breadcrumb: React.FC<Props> = ({
links,
children,
}) => {
return (
<nav className="breadcrumb" aria-label="breadcrumbs">
<ul>
{links && links.map(({ label, href }, index) => (
<li key={`breadcrumb-item-key-${index}`}>
<NavLink to={href}>{label}</NavLink>
</li>
))}
<li className="is-active">
<span className="">{children}</span>
</li>
</ul>
</nav>
);
}
export default Breadcrumb;

View file

@ -0,0 +1,23 @@
import React from 'react';
interface Props {
label: string;
title?: string;
}
const Indicator: React.FC<Props> = ({
label,
title,
children,
}) => {
return (
<div className="level-item level-left">
<div title={title ? title : label}>
<p className="heading">{label}</p>
<p className="title">{children}</p>
</div>
</div>
);
}
export default Indicator;

View file

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

View file

@ -0,0 +1,17 @@
import React from 'react';
const PageLoader: React.FC = () => (
<section className="hero is-fullheight-with-navbar">
<div className="hero-body has-text-centered" style={{ justifyContent: 'center' }}>
<div style={{ width: 300 }}>
<div className="subtitle">Loading...</div>
<progress
className="progress is-small is-primary is-inline-block"
max="100"
/>
</div>
</div>
</section>
);
export default PageLoader;

25
src/index.tsx Normal file
View file

@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import './theme/index.scss';
import AppContainer from './components/AppContainer';
import * as serviceWorker from './serviceWorker';
import configureStore from './redux/store/configureStore';
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<AppContainer />
</BrowserRouter>
</Provider>,
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

17
src/lib/api/brokers.ts Normal file
View file

@ -0,0 +1,17 @@
import {
Broker,
ClusterId,
BrokerMetrics,
} from 'lib/interfaces';
import {
BASE_URL,
BASE_PARAMS,
} from 'lib/constants';
export const getBrokers = (clusterId: ClusterId): Promise<Broker[]> =>
fetch(`${BASE_URL}/clusters/${clusterId}/brokers`, { ...BASE_PARAMS })
.then(res => res.json());
export const getBrokerMetrics = (clusterId: ClusterId): Promise<BrokerMetrics> =>
fetch(`${BASE_URL}/clusters/${clusterId}/metrics/broker`, { ...BASE_PARAMS })
.then(res => res.json());

11
src/lib/api/clusters.ts Normal file
View file

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

3
src/lib/api/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from './topics';
export * from './clusters';
export * from './brokers';

54
src/lib/api/topics.ts Normal file
View file

@ -0,0 +1,54 @@
import {
TopicName,
Topic,
ClusterId,
TopicDetails,
TopicConfig,
TopicFormData,
} from 'lib/interfaces';
import {
BASE_URL,
BASE_PARAMS,
} from 'lib/constants';
export const getTopicConfig = (clusterId: ClusterId, topicName: TopicName): Promise<TopicConfig[]> =>
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[]> =>
fetch(`${BASE_URL}/clusters/${clusterId}/topics`, { ...BASE_PARAMS })
.then(res => res.json());
export const postTopic = (clusterId: ClusterId, form: TopicFormData): Promise<Response> => {
const {
name,
partitions,
replicationFactor,
cleanupPolicy,
retentionBytes,
retentionMs,
maxMessageBytes,
minInSyncReplicas,
} = form;
const body = JSON.stringify({
name,
partitions,
replicationFactor,
configs: {
'cleanup.policy': cleanupPolicy,
'retention.ms': retentionMs,
'retention.bytes': retentionBytes,
'max.message.bytes': maxMessageBytes,
'min.insync.replicas': minInSyncReplicas,
}
});
return fetch(`${BASE_URL}/clusters/${clusterId}/topics`, {
...BASE_PARAMS,
method: 'POST',
body,
});
}

13
src/lib/constants.ts Normal file
View file

@ -0,0 +1,13 @@
export const BASE_PARAMS: RequestInit = {
credentials: 'include',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
};
export const BASE_URL = process.env.REACT_APP_API_URL;
export const TOPIC_NAME_VALIDATION_PATTERN = RegExp(/^[.,A-Za-z0-9_-]+$/);
export const MILLISECONDS_IN_DAY = 86_400_000;
export const BYTES_IN_GB = 1_073_741_824;

View file

@ -0,0 +1,30 @@
import React from 'react';
type Callback = () => any;
const useInterval = (callback: Callback, delay: number) => {
const savedCallback = React.useRef<Callback>();
React.useEffect(
() => {
savedCallback.current = callback;
},
[callback],
);
React.useEffect(
() => {
const tick = () => {
savedCallback.current && savedCallback.current()
};
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
},
[delay],
);
}
export default useInterval;

View file

@ -0,0 +1,33 @@
export type BrokerId = string;
export interface Broker {
brokerId: BrokerId;
bytesInPerSec: number;
segmentSize: number;
partitionReplicas: number;
bytesOutPerSec: number;
};
export enum ZooKeeperStatus { offline, online };
export interface BrokerDiskUsage {
brokerId: BrokerId;
segmentSize: number;
}
export interface BrokerMetrics {
brokerCount: number;
zooKeeperStatus: ZooKeeperStatus;
activeControllers: number;
networkPoolUsage: number;
requestPoolUsage: number;
onlinePartitionCount: number;
offlinePartitionCount: number;
underReplicatedPartitionCount: number;
diskUsageDistribution?: string;
diskUsage: BrokerDiskUsage[];
}
export interface BrokersState extends BrokerMetrics {
items: Broker[];
}

View file

@ -0,0 +1,18 @@
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;
bytesInPerSec: number;
bytesOutPerSec: number;
}

View file

@ -0,0 +1,35 @@
import { AnyAction } from 'redux';
import { ActionType } from 'typesafe-actions';
import { ThunkAction } from 'redux-thunk';
import * as topicsActions from 'redux/reducers/topics/actions';
import * as clustersActions from 'redux/reducers/clusters/actions';
import * as brokersActions from 'redux/reducers/brokers/actions';
import { TopicsState } from './topic';
import { Cluster } from './cluster';
import { BrokersState } from './broker';
import { LoaderState } from './loader';
export * from './topic';
export * from './cluster';
export * from './broker';
export * from './loader';
export enum FetchStatus {
notFetched = 'notFetched',
fetching = 'fetching',
fetched = 'fetched',
errorFetching = 'errorFetching',
}
export interface RootState {
topics: TopicsState;
clusters: Cluster[];
brokers: BrokersState;
loader: LoaderState;
}
export type Action = ActionType<typeof topicsActions | typeof clustersActions | typeof brokersActions>;
export type PromiseThunk<T> = ThunkAction<Promise<T>, RootState, undefined, AnyAction>;

View file

@ -0,0 +1,5 @@
import { FetchStatus } from 'lib/interfaces';
export interface LoaderState {
[key: string]: FetchStatus;
}

View file

@ -0,0 +1,60 @@
export type TopicName = string;
export enum CleanupPolicy {
Delete = 'delete',
Compact = 'compact',
}
export interface TopicConfig {
name: string;
value: string;
defaultValue: string;
}
export interface TopicReplica {
broker: number;
leader: boolean;
inSync: true;
}
export interface TopicPartition {
partition: number;
leader: number;
replicas: TopicReplica[];
}
export interface TopicDetails {
partitionCount?: number;
replicationFactor?: number;
replicas?: number;
segmentSize?: number;
inSyncReplicas?: number;
segmentCount?: number;
underReplicatedPartitions?: number;
}
export interface Topic {
name: TopicName;
internal: boolean;
partitions: TopicPartition[];
}
export interface TopicWithDetailedInfo extends Topic, TopicDetails {
config?: TopicConfig[];
}
export interface TopicsState {
byName: { [topicName: string]: TopicWithDetailedInfo },
allNames: TopicName[],
}
export interface TopicFormData {
name: string;
partitions: number;
replicationFactor: number;
minInSyncReplicas: number;
cleanupPolicy: string;
retentionMs: number;
retentionBytes: number;
maxMessageBytes: number;
};

12
src/lib/paths.ts Normal file
View file

@ -0,0 +1,12 @@
import { ClusterId, TopicName } from 'lib/interfaces';
const clusterPath = (clusterId: ClusterId) => `/clusters/${clusterId}`;
export const clusterBrokersPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/brokers`;
export const clusterTopicsPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/topics`;
export const clusterTopicNewPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/topics/new`;
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

@ -0,0 +1,13 @@
function formatBytes(bytes: number, decimals: number = 0) {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (bytes === 0) return [0, sizes[0]];
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return [parseFloat((bytes / Math.pow(k, i)).toFixed(dm)), sizes[i]];
}
export default formatBytes;

1
src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,9 @@
import topicsActionType from './topics/actionType';
import clustersActionType from './clusters/actionType';
import brokersActionType from './brokers/actionType';
export default {
...topicsActionType,
...clustersActionType,
...brokersActionType,
};

View file

@ -0,0 +1,11 @@
enum ActionType {
GET_BROKERS__REQUEST = 'GET_BROKERS__REQUEST',
GET_BROKERS__SUCCESS = 'GET_BROKERS__SUCCESS',
GET_BROKERS__FAILURE = 'GET_BROKERS__FAILURE',
GET_BROKER_METRICS__REQUEST = 'GET_BROKER_METRICS__REQUEST',
GET_BROKER_METRICS__SUCCESS = 'GET_BROKER_METRICS__SUCCESS',
GET_BROKER_METRICS__FAILURE = 'GET_BROKER_METRICS__FAILURE',
}
export default ActionType;

View file

@ -0,0 +1,15 @@
import { createAsyncAction} from 'typesafe-actions';
import ActionType from './actionType';
import { Broker, BrokerMetrics } from 'lib/interfaces';
export const fetchBrokersAction = createAsyncAction(
ActionType.GET_BROKERS__REQUEST,
ActionType.GET_BROKERS__SUCCESS,
ActionType.GET_BROKERS__FAILURE,
)<undefined, Broker[], undefined>();
export const fetchBrokerMetricsAction = createAsyncAction(
ActionType.GET_BROKER_METRICS__REQUEST,
ActionType.GET_BROKER_METRICS__SUCCESS,
ActionType.GET_BROKER_METRICS__FAILURE,
)<undefined, BrokerMetrics, undefined>();

View file

@ -0,0 +1,49 @@
import { Action, BrokersState, ZooKeeperStatus, BrokerMetrics } from 'lib/interfaces';
import actionType from 'redux/reducers/actionType';
export const initialState: BrokersState = {
items: [],
brokerCount: 0,
zooKeeperStatus: ZooKeeperStatus.offline,
activeControllers: 0,
networkPoolUsage: 0,
requestPoolUsage: 0,
onlinePartitionCount: 0,
offlinePartitionCount: 0,
underReplicatedPartitionCount: 0,
diskUsageDistribution: undefined,
diskUsage: [],
};
const updateBrokerSegmentSize = (state: BrokersState, payload: BrokerMetrics) => {
const brokers = state.items;
const { diskUsage } = payload;
const items = brokers.map((broker) => {
const brokerMetrics = diskUsage.find(({ brokerId }) => brokerId === broker.brokerId);
if (brokerMetrics !== undefined) {
return { ...broker, ...brokerMetrics };
}
return broker;
});
return { ...state, items, ...payload };
};
const reducer = (state = initialState, action: Action): BrokersState => {
switch (action.type) {
case actionType.GET_BROKERS__REQUEST:
return initialState;
case actionType.GET_BROKERS__SUCCESS:
return {
...state,
items: action.payload,
};
case actionType.GET_BROKER_METRICS__SUCCESS:
return updateBrokerSegmentSize(state, action.payload);
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,46 @@
import { createSelector } from 'reselect';
import { RootState, FetchStatus, BrokersState } from 'lib/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
const brokersState = ({ brokers }: RootState): BrokersState => brokers;
const getBrokerListFetchingStatus = createFetchingSelector('GET_BROKERS');
export const getIsBrokerListFetched = createSelector(
getBrokerListFetchingStatus,
(status) => status === FetchStatus.fetched,
);
const getBrokerList = createSelector(brokersState, ({ items }) => items);
export const getBrokerCount = createSelector(brokersState, ({ brokerCount }) => brokerCount);
export const getZooKeeperStatus = createSelector(brokersState, ({ zooKeeperStatus }) => zooKeeperStatus);
export const getActiveControllers = createSelector(brokersState, ({ activeControllers }) => activeControllers);
export const getNetworkPoolUsage = createSelector(brokersState, ({ networkPoolUsage }) => networkPoolUsage);
export const getRequestPoolUsage = createSelector(brokersState, ({ requestPoolUsage }) => requestPoolUsage);
export const getOnlinePartitionCount = createSelector(brokersState, ({ onlinePartitionCount }) => onlinePartitionCount);
export const getOfflinePartitionCount = createSelector(brokersState, ({ offlinePartitionCount }) => offlinePartitionCount);
export const getDiskUsageDistribution = createSelector(brokersState, ({ diskUsageDistribution }) => diskUsageDistribution);
export const getUnderReplicatedPartitionCount = createSelector(brokersState, ({ underReplicatedPartitionCount }) => underReplicatedPartitionCount);
export const getMinDiskUsage = createSelector(
getBrokerList,
(brokers) => {
if (brokers.length === 0) {
return 0;
}
return Math.min(...brokers.map(({ segmentSize }) => segmentSize));
},
);
export const getMaxDiskUsage = createSelector(
getBrokerList,
(brokers) => {
if (brokers.length === 0) {
return 0;
}
return Math.max(...brokers.map(({ segmentSize }) => segmentSize));
},
);

View file

@ -0,0 +1,27 @@
import { getBrokers, getBrokerMetrics } from 'lib/api';
import {
fetchBrokersAction,
fetchBrokerMetricsAction,
} from './actions';
import { PromiseThunk, ClusterId } from 'lib/interfaces';
export const fetchBrokers = (clusterId: ClusterId): PromiseThunk<void> => async (dispatch) => {
dispatch(fetchBrokersAction.request());
try {
const payload = await getBrokers(clusterId);
dispatch(fetchBrokersAction.success(payload));
} catch (e) {
dispatch(fetchBrokersAction.failure());
}
}
export const fetchBrokerMetrics = (clusterId: ClusterId): PromiseThunk<void> => async (dispatch) => {
dispatch(fetchBrokerMetricsAction.request());
try {
const payload = await getBrokerMetrics(clusterId);
dispatch(fetchBrokerMetricsAction.success(payload));
} catch (e) {
dispatch(fetchBrokerMetricsAction.failure());
}
}

View file

@ -0,0 +1,7 @@
enum ActionType {
GET_CLUSTERS__REQUEST = 'GET_CLUSTERS__REQUEST',
GET_CLUSTERS__SUCCESS = 'GET_CLUSTERS__SUCCESS',
GET_CLUSTERS__FAILURE = 'GET_CLUSTERS__FAILURE',
}
export default ActionType;

View file

@ -0,0 +1,9 @@
import { createAsyncAction} from 'typesafe-actions';
import ActionType from './actionType';
import { Cluster } from 'lib/interfaces';
export const fetchClusterListAction = createAsyncAction(
ActionType.GET_CLUSTERS__REQUEST,
ActionType.GET_CLUSTERS__SUCCESS,
ActionType.GET_CLUSTERS__FAILURE,
)<undefined, Cluster[], undefined>();

View file

@ -0,0 +1,15 @@
import { Cluster, Action } from 'lib/interfaces';
import actionType from 'redux/reducers/actionType';
export const initialState: Cluster[] = [];
const reducer = (state = initialState, action: Action): Cluster[] => {
switch (action.type) {
case actionType.GET_CLUSTERS__SUCCESS:
return action.payload;
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,28 @@
import { createSelector } from 'reselect';
import { Cluster, RootState, FetchStatus, ClusterStatus } from 'lib/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
const clustersState = ({ clusters }: RootState): Cluster[] => clusters;
const getClusterListFetchingStatus = createFetchingSelector('GET_CLUSTERS');
export const getIsClusterListFetched = createSelector(
getClusterListFetchingStatus,
(status) => status === FetchStatus.fetched,
);
export const getClusterList = createSelector(clustersState, (clusters) => clusters);
export const getOnlineClusters = createSelector(
getClusterList,
(clusters) => clusters.filter(
({ status }) => status === ClusterStatus.Online,
),
);
export const getOfflineClusters = createSelector(
getClusterList,
(clusters) => clusters.filter(
({ status }) => status === ClusterStatus.Offline,
),
);

View file

@ -0,0 +1,19 @@
import {
getClusters,
} from 'lib/api';
import {
fetchClusterListAction,
} from './actions';
import { Cluster, PromiseThunk } from 'lib/interfaces';
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

@ -0,0 +1,13 @@
import { combineReducers } from 'redux';
import topics from './topics/reducer';
import clusters from './clusters/reducer';
import brokers from './brokers/reducer';
import loader from './loader/reducer';
import { RootState } from 'lib/interfaces';
export default combineReducers<RootState>({
topics,
clusters,
brokers,
loader,
});

View file

@ -0,0 +1,35 @@
import { FetchStatus, Action, LoaderState } from 'lib/interfaces';
export const initialState: LoaderState = {};
const reducer = (state = initialState, action: Action): LoaderState => {
const { type } = action;
const matches = /(.*)__(REQUEST|SUCCESS|FAILURE)/.exec(type);
// not a *__REQUEST / *__SUCCESS / *__FAILURE actions, so we ignore them
if (!matches) return state;
const [, requestName, requestState] = matches;
switch (requestState) {
case 'REQUEST':
return {
...state,
[requestName]: FetchStatus.fetching,
};
case 'SUCCESS':
return {
...state,
[requestName]: FetchStatus.fetched,
};
case 'FAILURE':
return {
...state,
[requestName]: FetchStatus.errorFetching,
};
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,4 @@
import { RootState, FetchStatus } from 'lib/interfaces';
export const createFetchingSelector = (action: string) =>
(state: RootState) => (state.loader[action] || FetchStatus.notFetched);

View file

@ -0,0 +1,19 @@
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',
GET_TOPIC_CONFIG__REQUEST = 'GET_TOPIC_CONFIG__REQUEST',
GET_TOPIC_CONFIG__SUCCESS = 'GET_TOPIC_CONFIG__SUCCESS',
GET_TOPIC_CONFIG__FAILURE = 'GET_TOPIC_CONFIG__FAILURE',
POST_TOPIC__REQUEST = 'POST_TOPIC__REQUEST',
POST_TOPIC__SUCCESS = 'POST_TOPIC__SUCCESS',
POST_TOPIC__FAILURE = 'POST_TOPIC__FAILURE',
}
export default ActionType;

View file

@ -0,0 +1,27 @@
import { createAsyncAction} from 'typesafe-actions';
import ActionType from './actionType';
import { Topic, TopicDetails, TopicName, TopicConfig} from 'lib/interfaces';
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>();
export const fetchTopicConfigAction = createAsyncAction(
ActionType.GET_TOPIC_CONFIG__REQUEST,
ActionType.GET_TOPIC_CONFIG__SUCCESS,
ActionType.GET_TOPIC_CONFIG__FAILURE,
)<undefined, { topicName: TopicName, config: TopicConfig[] }, undefined>();
export const createTopicAction = createAsyncAction(
ActionType.POST_TOPIC__REQUEST,
ActionType.POST_TOPIC__SUCCESS,
ActionType.POST_TOPIC__FAILURE,
)<undefined, undefined, undefined>();

View file

@ -0,0 +1,61 @@
import { Action, TopicsState, Topic } from 'lib/interfaces';
import actionType from 'redux/reducers/actionType';
export const initialState: TopicsState = {
byName: {},
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 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,
}
}
}
case actionType.GET_TOPIC_CONFIG__SUCCESS:
return {
...state,
byName: {
...state.byName,
[action.payload.topicName]: {
...state.byName[action.payload.topicName],
config: action.payload.config,
}
}
}
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,61 @@
import { createSelector } from 'reselect';
import { RootState, TopicName, FetchStatus, TopicsState } from 'lib/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
const topicsState = ({ topics }: RootState): TopicsState => topics;
const getAllNames = (state: RootState) => topicsState(state).allNames;
const getTopicMap = (state: RootState) => topicsState(state).byName;
const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
const getTopicDetailsFetchingStatus = createFetchingSelector('GET_TOPIC_DETAILS');
const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
export const getIsTopicListFetched = createSelector(
getTopicListFetchingStatus,
(status) => status === FetchStatus.fetched,
);
export const getIsTopicDetailsFetched = createSelector(
getTopicDetailsFetchingStatus,
(status) => status === FetchStatus.fetched,
);
export const getTopicConfigFetched = createSelector(
getTopicConfigFetchingStatus,
(status) => status === FetchStatus.fetched,
);
export const getTopicCreated = createSelector(
getTopicCreationStatus,
(status) => status === FetchStatus.fetched,
);
export const getTopicList = createSelector(
getIsTopicListFetched,
getAllNames,
getTopicMap,
(isFetched, allNames, byName) => {
if (isFetched) {
return allNames.map((name) => byName[name])
}
return [];
},
);
export const getExternalTopicList = createSelector(
getTopicList,
(topics) => topics.filter(({ internal }) => !internal),
);
const getTopicName = (_: RootState, topicName: TopicName) => topicName;
export const getTopicByName = createSelector(
getTopicMap,
getTopicName,
(topics, topicName) => topics[topicName],
);
export const getTopicConfig = createSelector(getTopicByName, ({ config }) => config);

View file

@ -0,0 +1,54 @@
import {
getTopics,
getTopicDetails,
getTopicConfig,
postTopic,
} from 'lib/api';
import {
fetchTopicListAction,
fetchTopicDetailsAction,
fetchTopicConfigAction,
createTopicAction,
} from './actions';
import { PromiseThunk, ClusterId, TopicName, TopicFormData } from 'lib/interfaces';
export const fetchTopicList = (clusterId: ClusterId): PromiseThunk<void> => async (dispatch) => {
dispatch(fetchTopicListAction.request());
try {
const topics = await getTopics(clusterId);
dispatch(fetchTopicListAction.success(topics));
} catch (e) {
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());
}
}
export const fetchTopicConfig = (clusterId: ClusterId, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
dispatch(fetchTopicConfigAction.request());
try {
const config = await getTopicConfig(clusterId, topicName);
dispatch(fetchTopicConfigAction.success({ topicName, config }));
} catch (e) {
dispatch(fetchTopicConfigAction.failure());
}
}
export const createTopic = (clusterId: ClusterId, form: TopicFormData): PromiseThunk<void> => async (dispatch) => {
dispatch(createTopicAction.request());
try {
await postTopic(clusterId, form);
dispatch(createTopicAction.success());
} catch (e) {
dispatch(createTopicAction.failure());
}
}

View file

@ -0,0 +1,17 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../../reducers';
export default () => {
const middlewares = [thunk];
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const enhancer = composeEnhancers(
applyMiddleware(...middlewares),
);
const store = createStore(rootReducer, undefined, enhancer);
return store
};

View file

@ -0,0 +1,6 @@
import devConfigureStore from './dev';
import prodConfigureStore from './prod';
const configureStore = (process.env.NODE_ENV === 'production') ? prodConfigureStore : devConfigureStore
export default configureStore;

View file

@ -0,0 +1,13 @@
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../../reducers';
export default () => {
const middlewares = [thunk]
const enhancer = applyMiddleware(...middlewares);
const store = createStore(rootReducer, undefined, enhancer);
return store
};

145
src/serviceWorker.ts Normal file
View file

@ -0,0 +1,145 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
process.env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

5
src/setupTests.ts Normal file
View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

View file

@ -0,0 +1,70 @@
@import "../../node_modules/bulma/sass/utilities/_all.sass";
@import "../../node_modules/bulma/sass/base/_all.sass";
@import "../../node_modules/bulma/sass/elements/_all.sass";
@import "../../node_modules/bulma/sass/form/_all.sass";
@import "../../node_modules/bulma/sass/components/_all.sass";
@import "../../node_modules/bulma/sass/grid/_all.sass";
@import "../../node_modules/bulma/sass/layout/_all.sass";
@import "../../node_modules/bulma-switch/src/sass/index.sass";
.has {
&-text-overflow-ellipsis {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-margin {
&-right {
margin-right: 10px;
}
}
}
.breadcrumb li {
&.is-active > span {
padding: 0 0.75em;
}
&:first-child > span {
padding-left: 0;
}
}
.section {
animation: fadein .5s;
}
.select.is-block select {
width: 100%;
}
.notification {
&.is-light {
&.is-primary {
background-color: #ebfffc;
color: #00947e;
}
&.is-danger {
background-color: #feecf0;
color: #cc0f35;
}
}
}
.box {
&.is-hoverable {
cursor: pointer;
&:hover {
box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.2), 0 0px 0 1px rgba(10, 10, 10, 0.02);
}
}
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}

33
src/theme/index.scss Normal file
View file

@ -0,0 +1,33 @@
@import './bulma_overrides.scss';
#root, body, html {
width: 100%;
position: relative;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
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;
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"baseUrl": "src"
},
"include": [
"src"
]
}