commit
7193af2b52
90 changed files with 19015 additions and 1 deletions
2
.env
Normal file
2
.env
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Kafka REST API
|
||||
REACT_APP_API_URL=http://localhost:3004
|
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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*
|
39
README.md
39
README.md
|
@ -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
57
docker-compose.yaml
Normal 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
44
mock/index.js
Normal 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');
|
||||
});
|
42
mock/payload/brokerMetrics.json
Normal file
42
mock/payload/brokerMetrics.json
Normal 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
18
mock/payload/brokers.json
Normal 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
|
||||
}
|
||||
]
|
24
mock/payload/clusters.json
Normal file
24
mock/payload/clusters.json
Normal 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
|
||||
}
|
||||
]
|
132
mock/payload/topicConfigs.json
Normal file
132
mock/payload/topicConfigs.json
Normal 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"
|
||||
}
|
||||
]
|
10
mock/payload/topicDetails.json
Normal file
10
mock/payload/topicDetails.json
Normal 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
227
mock/payload/topics.json
Normal 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
15701
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
59
package.json
Normal file
59
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
16
public/index.html
Normal file
16
public/index.html
Normal 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
15
public/manifest.json
Normal 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
2
public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
30
src/components/App.scss
Normal file
30
src/components/App.scss
Normal 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
53
src/components/App.tsx
Normal 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;
|
17
src/components/AppContainer.tsx
Normal file
17
src/components/AppContainer.tsx
Normal 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);
|
121
src/components/Brokers/Brokers.tsx
Normal file
121
src/components/Brokers/Brokers.tsx
Normal 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;
|
38
src/components/Brokers/BrokersContainer.ts
Normal file
38
src/components/Brokers/BrokersContainer.ts
Normal 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);
|
59
src/components/Dashboard/ClustersWidget/ClusterWidget.tsx
Normal file
59
src/components/Dashboard/ClustersWidget/ClusterWidget.tsx
Normal 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;
|
77
src/components/Dashboard/ClustersWidget/ClustersWidget.tsx
Normal file
77
src/components/Dashboard/ClustersWidget/ClustersWidget.tsx
Normal 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;
|
|
@ -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);
|
17
src/components/Dashboard/Dashboard.tsx
Normal file
17
src/components/Dashboard/Dashboard.tsx
Normal 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;
|
46
src/components/Nav/ClusterMenu.tsx
Normal file
46
src/components/Nav/ClusterMenu.tsx
Normal 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;
|
38
src/components/Nav/Nav.tsx
Normal file
38
src/components/Nav/Nav.tsx
Normal 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;
|
11
src/components/Nav/NavConatiner.ts
Normal file
11
src/components/Nav/NavConatiner.ts
Normal 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);
|
69
src/components/Topics/Details/Details.tsx
Normal file
69
src/components/Topics/Details/Details.tsx
Normal 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;
|
20
src/components/Topics/Details/DetailsContainer.ts
Normal file
20
src/components/Topics/Details/DetailsContainer.ts
Normal 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)
|
||||
);
|
20
src/components/Topics/Details/Messages/Messages.tsx
Normal file
20
src/components/Topics/Details/Messages/Messages.tsx
Normal 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;
|
20
src/components/Topics/Details/Messages/MessagesContainer.ts
Normal file
20
src/components/Topics/Details/Messages/MessagesContainer.ts
Normal 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)
|
||||
);
|
79
src/components/Topics/Details/Overview/Overview.tsx
Normal file
79
src/components/Topics/Details/Overview/Overview.tsx
Normal 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;
|
30
src/components/Topics/Details/Overview/OverviewContainer.ts
Normal file
30
src/components/Topics/Details/Overview/OverviewContainer.ts
Normal 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)
|
||||
);
|
71
src/components/Topics/Details/Settings/Settings.tsx
Normal file
71
src/components/Topics/Details/Settings/Settings.tsx
Normal 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;
|
34
src/components/Topics/Details/Settings/SettingsContainer.ts
Normal file
34
src/components/Topics/Details/Settings/SettingsContainer.ts
Normal 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)
|
||||
);
|
80
src/components/Topics/List/List.tsx
Normal file
80
src/components/Topics/List/List.tsx
Normal 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;
|
21
src/components/Topics/List/ListContainer.ts
Normal file
21
src/components/Topics/List/ListContainer.ts
Normal 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)
|
||||
);
|
40
src/components/Topics/List/ListItem.tsx
Normal file
40
src/components/Topics/List/ListItem.tsx
Normal 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;
|
247
src/components/Topics/New/New.tsx
Normal file
247
src/components/Topics/New/New.tsx
Normal 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;
|
33
src/components/Topics/New/NewContainer.ts
Normal file
33
src/components/Topics/New/NewContainer.ts
Normal 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)
|
||||
);
|
39
src/components/Topics/Topics.tsx
Normal file
39
src/components/Topics/Topics.tsx
Normal 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;
|
23
src/components/Topics/TopicsContainer.ts
Normal file
23
src/components/Topics/TopicsContainer.ts
Normal 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);
|
34
src/components/common/Breadcrumb/Breadcrumb.tsx
Normal file
34
src/components/common/Breadcrumb/Breadcrumb.tsx
Normal 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;
|
23
src/components/common/Dashboard/Indicator.tsx
Normal file
23
src/components/common/Dashboard/Indicator.tsx
Normal 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;
|
28
src/components/common/Dashboard/MetricsWrapper.tsx
Normal file
28
src/components/common/Dashboard/MetricsWrapper.tsx
Normal 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;
|
17
src/components/common/PageLoader/PageLoader.tsx
Normal file
17
src/components/common/PageLoader/PageLoader.tsx
Normal 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
25
src/index.tsx
Normal 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
17
src/lib/api/brokers.ts
Normal 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
11
src/lib/api/clusters.ts
Normal 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
3
src/lib/api/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './topics';
|
||||
export * from './clusters';
|
||||
export * from './brokers';
|
54
src/lib/api/topics.ts
Normal file
54
src/lib/api/topics.ts
Normal 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
13
src/lib/constants.ts
Normal 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;
|
30
src/lib/hooks/useInterval.ts
Normal file
30
src/lib/hooks/useInterval.ts
Normal 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;
|
33
src/lib/interfaces/broker.ts
Normal file
33
src/lib/interfaces/broker.ts
Normal 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[];
|
||||
}
|
18
src/lib/interfaces/cluster.ts
Normal file
18
src/lib/interfaces/cluster.ts
Normal 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;
|
||||
}
|
35
src/lib/interfaces/index.ts
Normal file
35
src/lib/interfaces/index.ts
Normal 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>;
|
5
src/lib/interfaces/loader.ts
Normal file
5
src/lib/interfaces/loader.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { FetchStatus } from 'lib/interfaces';
|
||||
|
||||
export interface LoaderState {
|
||||
[key: string]: FetchStatus;
|
||||
}
|
60
src/lib/interfaces/topic.ts
Normal file
60
src/lib/interfaces/topic.ts
Normal 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
12
src/lib/paths.ts
Normal 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`;
|
13
src/lib/utils/formatBytes.ts
Normal file
13
src/lib/utils/formatBytes.ts
Normal 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
1
src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
9
src/redux/reducers/actionType.ts
Normal file
9
src/redux/reducers/actionType.ts
Normal 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,
|
||||
};
|
11
src/redux/reducers/brokers/actionType.ts
Normal file
11
src/redux/reducers/brokers/actionType.ts
Normal 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;
|
15
src/redux/reducers/brokers/actions.ts
Normal file
15
src/redux/reducers/brokers/actions.ts
Normal 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>();
|
49
src/redux/reducers/brokers/reducer.ts
Normal file
49
src/redux/reducers/brokers/reducer.ts
Normal 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;
|
46
src/redux/reducers/brokers/selectors.ts
Normal file
46
src/redux/reducers/brokers/selectors.ts
Normal 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));
|
||||
},
|
||||
);
|
27
src/redux/reducers/brokers/thunks.ts
Normal file
27
src/redux/reducers/brokers/thunks.ts
Normal 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());
|
||||
}
|
||||
}
|
7
src/redux/reducers/clusters/actionType.ts
Normal file
7
src/redux/reducers/clusters/actionType.ts
Normal 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;
|
9
src/redux/reducers/clusters/actions.ts
Normal file
9
src/redux/reducers/clusters/actions.ts
Normal 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>();
|
15
src/redux/reducers/clusters/reducer.ts
Normal file
15
src/redux/reducers/clusters/reducer.ts
Normal 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;
|
28
src/redux/reducers/clusters/selectors.ts
Normal file
28
src/redux/reducers/clusters/selectors.ts
Normal 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,
|
||||
),
|
||||
);
|
19
src/redux/reducers/clusters/thunks.ts
Normal file
19
src/redux/reducers/clusters/thunks.ts
Normal 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());
|
||||
}
|
||||
}
|
13
src/redux/reducers/index.ts
Normal file
13
src/redux/reducers/index.ts
Normal 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,
|
||||
});
|
35
src/redux/reducers/loader/reducer.ts
Normal file
35
src/redux/reducers/loader/reducer.ts
Normal 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;
|
4
src/redux/reducers/loader/selectors.ts
Normal file
4
src/redux/reducers/loader/selectors.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { RootState, FetchStatus } from 'lib/interfaces';
|
||||
|
||||
export const createFetchingSelector = (action: string) =>
|
||||
(state: RootState) => (state.loader[action] || FetchStatus.notFetched);
|
19
src/redux/reducers/topics/actionType.ts
Normal file
19
src/redux/reducers/topics/actionType.ts
Normal 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;
|
27
src/redux/reducers/topics/actions.ts
Normal file
27
src/redux/reducers/topics/actions.ts
Normal 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>();
|
61
src/redux/reducers/topics/reducer.ts
Normal file
61
src/redux/reducers/topics/reducer.ts
Normal 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;
|
61
src/redux/reducers/topics/selectors.ts
Normal file
61
src/redux/reducers/topics/selectors.ts
Normal 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);
|
54
src/redux/reducers/topics/thunks.ts
Normal file
54
src/redux/reducers/topics/thunks.ts
Normal 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());
|
||||
}
|
||||
}
|
17
src/redux/store/configureStore/dev.ts
Normal file
17
src/redux/store/configureStore/dev.ts
Normal 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
|
||||
};
|
6
src/redux/store/configureStore/index.ts
Normal file
6
src/redux/store/configureStore/index.ts
Normal 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;
|
13
src/redux/store/configureStore/prod.ts
Normal file
13
src/redux/store/configureStore/prod.ts
Normal 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
145
src/serviceWorker.ts
Normal 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
5
src/setupTests.ts
Normal 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';
|
70
src/theme/bulma_overrides.scss
Normal file
70
src/theme/bulma_overrides.scss
Normal 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
33
src/theme/index.scss
Normal 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
26
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Loading…
Add table
Reference in a new issue