Browse Source

Consumer group details view. (#18)

* Added concumer groups list with search.

* Added consumer group details view.

* Minor tweaks.

* Backend group details (#19)

* temp result commit

* consumer group details api done

* fixes

* changed calls from loop into vars, changed foreach to map

* changes

* removed redundant import

* Refactoring, fixed contract

* Fixed useless group query

* fix-consumer-groups-retaining-cluster-on-cluster-switch

Co-authored-by: Roman Nedzvetskiy <roman@Romans-MacBook-Pro.local>
Co-authored-by: German Osin <german.osin@gmail.com>
Co-authored-by: Azat Gataullin <gataniel@gmail.com>

* Add loader, fix details

* Add empty text for consumer groups

Co-authored-by: Sofia Shnaidman <sshnaidman@provectus.com>
Co-authored-by: Roman Nedzvetskiy <gmcodemail@gmail.com>
Co-authored-by: Roman Nedzvetskiy <roman@Romans-MacBook-Pro.local>
Co-authored-by: German Osin <german.osin@gmail.com>
Co-authored-by: Azat Gataullin <gataniel@gmail.com>
Co-authored-by: Maxim Tereshin <tereshin93@gmail.com>
soffest 5 years ago
parent
commit
0815739d79
33 changed files with 1156 additions and 110 deletions
  1. 5 0
      kafka-ui-api/pom.xml
  2. 1 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/mapper/ClusterMapper.java
  3. 2 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/model/InternalClusterMetrics.java
  4. 57 4
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/service/ClusterService.java
  5. 33 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/util/ClusterUtil.java
  6. 5 2
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/kafka/KafkaService.java
  7. 6 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/rest/MetricsRestController.java
  8. 5 2
      kafka-ui-react-app/.eslintrc.json
  9. 4 1
      kafka-ui-react-app/mock/index.js
  10. 29 0
      kafka-ui-react-app/mock/payload/consumerGroupDetails.json
  11. 584 0
      kafka-ui-react-app/package-lock.json
  12. 6 1
      kafka-ui-react-app/src/components/ConsumerGroups/ConsumerGroups.tsx
  13. 86 0
      kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx
  14. 29 0
      kafka-ui-react-app/src/components/ConsumerGroups/Details/DetailsContainer.ts
  15. 33 0
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ListItem.tsx
  16. 39 33
      kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx
  17. 14 15
      kafka-ui-react-app/src/components/ConsumerGroups/List/ListItem.tsx
  18. 38 14
      kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx
  19. 2 2
      kafka-ui-react-app/src/components/Nav/Nav.tsx
  20. 7 6
      kafka-ui-react-app/src/components/Topics/New/New.tsx
  21. 4 0
      kafka-ui-react-app/src/redux/actionType.ts
  22. 7 1
      kafka-ui-react-app/src/redux/actions/actions.ts
  23. 11 0
      kafka-ui-react-app/src/redux/actions/thunks.ts
  24. 5 1
      kafka-ui-react-app/src/redux/api/consumerGroups.ts
  25. 5 5
      kafka-ui-react-app/src/redux/api/topics.ts
  26. 27 1
      kafka-ui-react-app/src/redux/interfaces/consumerGroup.ts
  27. 2 2
      kafka-ui-react-app/src/redux/interfaces/index.ts
  28. 1 1
      kafka-ui-react-app/src/redux/interfaces/topic.ts
  29. 33 11
      kafka-ui-react-app/src/redux/reducers/brokers/selectors.ts
  30. 39 3
      kafka-ui-react-app/src/redux/reducers/consumerGroups/reducer.ts
  31. 32 3
      kafka-ui-react-app/src/redux/reducers/consumerGroups/selectors.ts
  32. 4 0
      kafka-ui-react-app/src/theme/index.scss
  33. 1 0
      pom.xml

+ 5 - 0
kafka-ui-api/pom.xml

@@ -49,6 +49,11 @@
             <artifactId>kafka-clients</artifactId>
             <version>${kafka-clients.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.kafka</groupId>
+            <artifactId>kafka_2.13</artifactId>
+            <version>${kafka.version}</version>
+        </dependency>
         <dependency>
             <groupId>com.101tec</groupId>
             <artifactId>zkclient</artifactId>

+ 1 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/mapper/ClusterMapper.java

@@ -8,6 +8,7 @@ import com.provectus.kafka.ui.cluster.model.KafkaCluster;
 import com.provectus.kafka.ui.model.*;
 import org.mapstruct.Mapper;
 import org.mapstruct.Mapping;
+import org.mapstruct.ValueMapping;
 
 @Mapper(componentModel = "spring")
 public interface ClusterMapper {

+ 2 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/model/InternalClusterMetrics.java

@@ -1,5 +1,6 @@
 package com.provectus.kafka.ui.cluster.model;
 
+import com.provectus.kafka.ui.model.ServerStatus;
 import lombok.Builder;
 import lombok.Data;
 
@@ -22,4 +23,5 @@ public class InternalClusterMetrics {
     //TODO: find way to fill
     private final int segmentSize;
     private final int segmentCount;
+    private final int zooKeeperStatus;
 }

+ 57 - 4
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/service/ClusterService.java

@@ -8,14 +8,20 @@ import com.provectus.kafka.ui.kafka.KafkaService;
 import com.provectus.kafka.ui.model.*;
 import lombok.RequiredArgsConstructor;
 import lombok.SneakyThrows;
+import org.apache.kafka.clients.admin.ConsumerGroupListing;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 @Service
 @RequiredArgsConstructor
@@ -66,10 +72,57 @@ public class ClusterService {
     }
 
     @SneakyThrows
-    public Mono<List<ConsumerGroup>> getConsumerGroups(String clusterName) {
+    public Mono<ConsumerGroupDetails> getConsumerGroupDetail(String clusterName, String consumerGroupId) {
+        var cluster = clustersStorage.getClusterByName(clusterName).orElseThrow(Throwable::new);
+
+        return kafkaService.getOrCreateAdminClient(cluster).map(ac ->
+                                ac.describeConsumerGroups(Collections.singletonList(consumerGroupId)).all()
+            ).flatMap(groups ->
+                groupMetadata(cluster, consumerGroupId)
+                    .flatMap(offsets -> {
+                        Map<TopicPartition, Long> endOffsets = topicPartitionsEndOffsets(cluster, offsets.keySet());
+                            return ClusterUtil.toMono(groups).map(s -> s.get(consumerGroupId).members().stream()
+                                        .flatMap(c -> Stream.of(ClusterUtil.convertToConsumerTopicPartitionDetails(c, offsets, endOffsets)))
+                                    .collect(Collectors.toList()).stream().flatMap(t -> t.stream().flatMap(Stream::of)).collect(Collectors.toList()));
+                    })
+            )
+            .map(c -> new ConsumerGroupDetails().consumers(c).consumerGroupId(consumerGroupId));
+
+    }
+
+    public Mono<Map<TopicPartition, OffsetAndMetadata>> groupMetadata(KafkaCluster cluster, String consumerGroupId) {
+        return
+                kafkaService.getOrCreateAdminClient(cluster)
+                        .map(ac -> ac.listConsumerGroupOffsets(consumerGroupId).partitionsToOffsetAndMetadata())
+                        .flatMap(ClusterUtil::toMono);
+    }
+
+    public Map<TopicPartition, Long> topicPartitionsEndOffsets(KafkaCluster cluster, Collection<TopicPartition> topicPartitions) {
+        Properties properties = new Properties();
+        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, cluster.getBootstrapServers());
+        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+        properties.put(ConsumerConfig.GROUP_ID_CONFIG, UUID.randomUUID().toString());
+
+        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
+            return consumer.endOffsets(topicPartitions);
+        }
+    }
+
+    @SneakyThrows
+    public Mono<List<ConsumerGroup>> getConsumerGroups (String clusterName) {
             return clustersStorage.getClusterByName(clusterName)
                     .map(kafkaService::getConsumerGroups)
                     .orElse(Mono.empty());
+
+//        var cluster = clustersStorage.getClusterByName(clusterName).orElseThrow(Throwable::new);
+//            return kafkaService.getOrCreateAdminClient(cluster).map(ac -> ac.listConsumerGroups().all())
+//                    .flatMap(s ->
+//                            kafkaService.getOrCreateAdminClient(cluster).flatMap(ac ->
+//                                ClusterUtil.toMono(s).map(s1 -> s1.stream().map(ConsumerGroupListing::groupId).collect(Collectors.toList())).map(ac::describeConsumerGroups)
+//                    ))
+//                    .flatMap(s -> ClusterUtil.toMono(s.all()).map(details -> details.values().stream()
+//                            .map(c -> ClusterUtil.convertToConsumerGroup(c, cluster)).collect(Collectors.toList())));
     }
 
     public Flux<Broker> getBrokers (String clusterName) {

+ 33 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/util/ClusterUtil.java

@@ -2,14 +2,21 @@ package com.provectus.kafka.ui.cluster.util;
 
 import com.provectus.kafka.ui.cluster.model.*;
 import com.provectus.kafka.ui.model.ConsumerGroup;
+import com.provectus.kafka.ui.model.ConsumerTopicPartitionDetail;
+import com.provectus.kafka.ui.model.ServerStatus;
 import org.apache.kafka.clients.admin.ConfigEntry;
 import org.apache.kafka.clients.admin.ConsumerGroupDescription;
+import org.apache.kafka.clients.admin.MemberDescription;
 import org.apache.kafka.clients.admin.TopicDescription;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.TopicPartition;
 import reactor.core.publisher.Mono;
 
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import static com.provectus.kafka.ui.kafka.KafkaConstants.TOPIC_DEFAULT_CONFIGS;
 import static org.apache.kafka.common.config.TopicConfig.MESSAGE_FORMAT_VERSION_CONFIG;
@@ -30,11 +37,32 @@ public class ClusterUtil {
         ConsumerGroup consumerGroup = new ConsumerGroup();
         consumerGroup.setConsumerGroupId(c.groupId());
         consumerGroup.setNumConsumers(c.members().size());
-        int numTopics = c.members().stream().mapToInt( m -> m.assignment().topicPartitions().size()).sum();
+        int numTopics = c.members().stream().flatMap(m -> m.assignment().topicPartitions().stream().flatMap(t -> Stream.of(t.topic()))).collect(Collectors.toSet()).size();
         consumerGroup.setNumTopics(numTopics);
         return consumerGroup;
     }
 
+    public static List<ConsumerTopicPartitionDetail> convertToConsumerTopicPartitionDetails(
+            MemberDescription consumer,
+            Map<TopicPartition, OffsetAndMetadata> groupOffsets,
+            Map<TopicPartition, Long> endOffsets
+    ) {
+        return consumer.assignment().topicPartitions().stream()
+                .map(tp -> {
+                    Long currentOffset = groupOffsets.get(tp).offset();
+                    Long endOffset = endOffsets.get(tp);
+                    ConsumerTopicPartitionDetail cd = new ConsumerTopicPartitionDetail();
+                    cd.setConsumerId(consumer.consumerId());
+                    cd.setTopic(tp.topic());
+                    cd.setPartition(tp.partition());
+                    cd.setCurrentOffset(currentOffset);
+                    cd.setEndOffset(endOffset);
+                    cd.setMessagesBehind(endOffset - currentOffset);
+                    return cd;
+                }).collect(Collectors.toList());
+    }
+
+
     public static InternalTopicConfig mapToInternalTopicConfig(ConfigEntry configEntry) {
         InternalTopicConfig.InternalTopicConfigBuilder builder = InternalTopicConfig.builder()
                 .name(configEntry.name())
@@ -95,4 +123,8 @@ public class ClusterUtil {
         return topic.build();
     }
 
+    public static int convertToIntServerStatus(ServerStatus serverStatus) {
+        return serverStatus.equals(ServerStatus.ONLINE) ? 1 : 0;
+    }
+
 }

+ 5 - 2
kafka-ui-api/src/main/java/com/provectus/kafka/ui/kafka/KafkaService.java

@@ -71,12 +71,15 @@ public class KafkaService {
 
         InternalClusterMetrics clusterMetrics = metricsBuilder
                 .activeControllers(brokersMetrics.getActiveControllers())
+                .topicCount(topicsMetrics.getTopicCount())
                 .brokerCount(brokersMetrics.getBrokerCount())
                 .underReplicatedPartitionCount(topicsMetrics.getUnderReplicatedPartitionCount())
                 .inSyncReplicasCount(topicsMetrics.getInSyncReplicasCount())
                 .outOfSyncReplicasCount(topicsMetrics.getOutOfSyncReplicasCount())
                 .onlinePartitionCount(topicsMetrics.getOnlinePartitionCount())
-                .offlinePartitionCount(topicsMetrics.getOfflinePartitionCount()).build();
+                .offlinePartitionCount(topicsMetrics.getOfflinePartitionCount())
+                .zooKeeperStatus(ClusterUtil.convertToIntServerStatus(zookeeperStatus))
+                .build();
 
         return currentCluster.toBuilder()
                 .status(ServerStatus.ONLINE)
@@ -110,6 +113,7 @@ public class KafkaService {
                 .outOfSyncReplicasCount(outOfSyncReplicasCount)
                 .onlinePartitionCount(onlinePartitionCount)
                 .offlinePartitionCount(offlinePartitionCount)
+                .topicCount(topics.size())
                 .build();
     }
 
@@ -138,7 +142,6 @@ public class KafkaService {
                             builder.brokerCount(brokers.size()).activeControllers(c != null ? 1 : 0);
                             // TODO: fill bytes in/out metrics
                             List<Integer> brokerIds = brokers.stream().map(Node::id).collect(Collectors.toList());
-
                             return builder.build();
                         }
                     )

+ 6 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/rest/MetricsRestController.java

@@ -77,10 +77,15 @@ public class MetricsRestController implements ApiClustersApi {
     }
 
     @Override
-    public Mono<ResponseEntity<Flux<ConsumerGroup>>> getConsumerGroup(String clusterName, ServerWebExchange exchange) {
+    public Mono<ResponseEntity<Flux<ConsumerGroup>>> getConsumerGroups(String clusterName, ServerWebExchange exchange) {
         return clusterService.getConsumerGroups(clusterName)
                 .map(Flux::fromIterable)
                 .map(ResponseEntity::ok)
                 .switchIfEmpty(Mono.just(ResponseEntity.notFound().build())); // TODO: check behaviour on cluster not found and empty groups list
     }
+
+    @Override
+    public Mono<ResponseEntity<ConsumerGroupDetails>> getConsumerGroup(String clusterName, String consumerGroupId, ServerWebExchange exchange) {
+        return clusterService.getConsumerGroupDetail(clusterName, consumerGroupId).map(ResponseEntity::ok);
+    }
 }

+ 5 - 2
kafka-ui-react-app/.eslintrc.json

@@ -39,7 +39,9 @@
     "react/jsx-filename-extension": [
       1,
       { "extensions": [".js", ".jsx", ".ts", ".tsx"] }
-    ]
+    ],
+    "jsx-a11y/label-has-associated-control": "off",
+    "no-param-reassign": [2, { "props": false }]
   },
   "overrides": [
     {
@@ -52,7 +54,8 @@
   "settings": {
     "import/resolver": {
       "node": {
-        "extensions": [".js", ".jsx", ".ts", ".tsx"]
+        "extensions": [".js", ".jsx", ".ts", ".tsx"],
+        "paths": ["src"]
       }
     }
   }

+ 4 - 1
kafka-ui-react-app/mock/index.js

@@ -6,6 +6,7 @@ const topics = require('./payload/topics.json');
 const topicDetails = require('./payload/topicDetails.json');
 const topicConfigs = require('./payload/topicConfigs.json');
 const consumerGroups = require('./payload/consumerGroups.json');
+const consumerGroupDetails = require('./payload/consumerGroupDetails.json');
 
 const db = {
     clusters,
@@ -14,7 +15,8 @@ const db = {
     topics: topics.map((topic) => ({...topic, id: topic.name})),
     topicDetails,
     topicConfigs,
-    consumerGroups: consumerGroups.map((group) => ({...group, id: group.consumerGroupId}))
+    consumerGroups: consumerGroups.map((group) => ({...group, id: group.consumerGroupId})),
+    consumerGroupDetails
 };
 const server = jsonServer.create();
 const router = jsonServer.router(db);
@@ -34,6 +36,7 @@ server.use(
     '/clusters/:clusterName/metrics/broker': '/brokerMetrics/:clusterName',
     '/clusters/:clusterName/topics/:id': '/topicDetails',
     '/clusters/:clusterName/topics/:id/config': '/topicConfigs',
+    '/clusters/:clusterName/consumer-groups/:id': '/consumerGroupDetails',
   })
 );
 

+ 29 - 0
kafka-ui-react-app/mock/payload/consumerGroupDetails.json

@@ -0,0 +1,29 @@
+{
+    "consumerGroupId": "_fake.cluster.consumer_1",
+    "consumers": [
+        {
+            "consumerId": "_fake.cluster.consumer_1-1-1",
+            "topic": "my-topic",
+            "partition": 0,
+            "messagesBehind": 1246,
+            "currentOffset": 2834,
+            "endOffset": 2835
+        },
+        {
+            "consumerId": "_fake.cluster.consumer_2-2-2",
+            "topic": "docker-connect-status",
+            "partition": 1,
+            "messagesBehind": 678,
+            "currentOffset": 234,
+            "endOffset": 246
+        },
+        {
+            "consumerId": "_fake.cluster.consumer_2-2-2",
+            "topic": "docker-connect-status",
+            "partition": 2,
+            "messagesBehind": 143,
+            "currentOffset": 123,
+            "endOffset": 134
+        }
+    ]
+}

+ 584 - 0
kafka-ui-react-app/package-lock.json

@@ -1241,6 +1241,15 @@
       "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
       "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw=="
     },
+    "@samverschueren/stream-to-observable": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz",
+      "integrity": "sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg==",
+      "dev": true,
+      "requires": {
+        "any-observable": "^0.3.0"
+      }
+    },
     "@sheerun/mutationobserver-shim": {
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz",
@@ -2046,6 +2055,12 @@
         "color-convert": "^1.9.0"
       }
     },
+    "any-observable": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz",
+      "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==",
+      "dev": true
+    },
     "anymatch": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
@@ -3870,6 +3885,60 @@
         "restore-cursor": "^3.1.0"
       }
     },
+    "cli-truncate": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
+      "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
+      "dev": true,
+      "requires": {
+        "slice-ansi": "^3.0.0",
+        "string-width": "^4.2.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+          "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+          "dev": true,
+          "requires": {
+            "@types/color-name": "^1.1.1",
+            "color-convert": "^2.0.1"
+          }
+        },
+        "astral-regex": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+          "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+          "dev": true
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "slice-ansi": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
+          "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "astral-regex": "^2.0.0",
+            "is-fullwidth-code-point": "^3.0.0"
+          }
+        }
+      }
+    },
     "cli-width": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz",
@@ -3907,6 +3976,12 @@
         }
       }
     },
+    "clone": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
+      "dev": true
+    },
     "clone-deep": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz",
@@ -4657,6 +4732,12 @@
         "mimic-response": "^1.0.0"
       }
     },
+    "dedent": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
+      "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=",
+      "dev": true
+    },
     "deep-equal": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
@@ -4689,6 +4770,15 @@
         "ip-regex": "^2.1.0"
       }
     },
+    "defaults": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
+      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
+      "dev": true,
+      "requires": {
+        "clone": "^1.0.2"
+      }
+    },
     "defer-to-connect": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.1.tgz",
@@ -5067,6 +5157,12 @@
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.322.tgz",
       "integrity": "sha512-Tc8JQEfGQ1MzfSzI/bTlSr7btJv/FFO7Yh6tanqVmIWOuNCu6/D1MilIEgLtmWqIrsv+o4IjpLAhgMBr/ncNAA=="
     },
+    "elegant-spinner": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-2.0.0.tgz",
+      "integrity": "sha512-5YRYHhvhYzV/FC4AiMdeSIg3jAYGq9xFvbhZMpPlJoBsfYgrw2DSCYeXfat6tYBu45PWiyRr3+flaCPPmviPaA==",
+      "dev": true
+    },
     "elliptic": {
       "version": "6.5.2",
       "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
@@ -5147,6 +5243,15 @@
         }
       }
     },
+    "enquirer": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.5.tgz",
+      "integrity": "sha512-BNT1C08P9XD0vNg3J475yIUG+mVdp9T6towYFHUv897X0KoHBjB1shyrNmhmtHWKP17iSWgo7Gqh7BBuzLZMSA==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "^3.2.1"
+      }
+    },
     "entities": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
@@ -7306,6 +7411,12 @@
       "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
       "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM="
     },
+    "human-signals": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
+      "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
+      "dev": true
+    },
     "husky": {
       "version": "4.2.5",
       "resolved": "https://registry.npmjs.org/husky/-/husky-4.2.5.tgz",
@@ -9424,6 +9535,352 @@
       "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
       "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="
     },
+    "lint-staged": {
+      "version": "10.2.2",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.2.2.tgz",
+      "integrity": "sha512-78kNqNdDeKrnqWsexAmkOU3Z5wi+1CsQmUmfCuYgMTE8E4rAIX8RHW7xgxwAZ+LAayb7Cca4uYX4P3LlevzjVg==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.0.0",
+        "commander": "^5.0.0",
+        "cosmiconfig": "^6.0.0",
+        "debug": "^4.1.1",
+        "dedent": "^0.7.0",
+        "execa": "^4.0.0",
+        "listr2": "1.3.8",
+        "log-symbols": "^3.0.0",
+        "micromatch": "^4.0.2",
+        "normalize-path": "^3.0.0",
+        "please-upgrade-node": "^3.2.0",
+        "string-argv": "0.3.1",
+        "stringify-object": "^3.3.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+          "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+          "dev": true,
+          "requires": {
+            "@types/color-name": "^1.1.1",
+            "color-convert": "^2.0.1"
+          }
+        },
+        "braces": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+          "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+          "dev": true,
+          "requires": {
+            "fill-range": "^7.0.1"
+          }
+        },
+        "chalk": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.0.0.tgz",
+          "integrity": "sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "commander": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
+          "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
+          "dev": true
+        },
+        "cosmiconfig": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+          "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+          "dev": true,
+          "requires": {
+            "@types/parse-json": "^4.0.0",
+            "import-fresh": "^3.1.0",
+            "parse-json": "^5.0.0",
+            "path-type": "^4.0.0",
+            "yaml": "^1.7.2"
+          }
+        },
+        "cross-spawn": {
+          "version": "7.0.2",
+          "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.2.tgz",
+          "integrity": "sha512-PD6G8QG3S4FK/XCGFbEQrDqO2AnMMsy0meR7lerlIOHAAbkuavGU/pOqprrlvfTNjvowivTeBsjebAL0NSoMxw==",
+          "dev": true,
+          "requires": {
+            "path-key": "^3.1.0",
+            "shebang-command": "^2.0.0",
+            "which": "^2.0.1"
+          }
+        },
+        "execa": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/execa/-/execa-4.0.1.tgz",
+          "integrity": "sha512-SCjM/zlBdOK8Q5TIjOn6iEHZaPHFsMoTxXQ2nvUvtPnuohz3H2dIozSg+etNR98dGoYUp2ENSKLL/XaMmbxVgw==",
+          "dev": true,
+          "requires": {
+            "cross-spawn": "^7.0.0",
+            "get-stream": "^5.0.0",
+            "human-signals": "^1.1.1",
+            "is-stream": "^2.0.0",
+            "merge-stream": "^2.0.0",
+            "npm-run-path": "^4.0.0",
+            "onetime": "^5.1.0",
+            "signal-exit": "^3.0.2",
+            "strip-final-newline": "^2.0.0"
+          }
+        },
+        "fill-range": {
+          "version": "7.0.1",
+          "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+          "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+          "dev": true,
+          "requires": {
+            "to-regex-range": "^5.0.1"
+          }
+        },
+        "get-stream": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz",
+          "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==",
+          "dev": true,
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "import-fresh": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz",
+          "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==",
+          "dev": true,
+          "requires": {
+            "parent-module": "^1.0.0",
+            "resolve-from": "^4.0.0"
+          }
+        },
+        "is-number": {
+          "version": "7.0.0",
+          "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+          "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+          "dev": true
+        },
+        "is-stream": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz",
+          "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==",
+          "dev": true
+        },
+        "micromatch": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
+          "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
+          "dev": true,
+          "requires": {
+            "braces": "^3.0.1",
+            "picomatch": "^2.0.5"
+          }
+        },
+        "normalize-path": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+          "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+          "dev": true
+        },
+        "npm-run-path": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+          "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+          "dev": true,
+          "requires": {
+            "path-key": "^3.0.0"
+          }
+        },
+        "parse-json": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz",
+          "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==",
+          "dev": true,
+          "requires": {
+            "@babel/code-frame": "^7.0.0",
+            "error-ex": "^1.3.1",
+            "json-parse-better-errors": "^1.0.1",
+            "lines-and-columns": "^1.1.6"
+          }
+        },
+        "path-key": {
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+          "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+          "dev": true
+        },
+        "path-type": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+          "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+          "dev": true
+        },
+        "resolve-from": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+          "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+          "dev": true
+        },
+        "shebang-command": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+          "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+          "dev": true,
+          "requires": {
+            "shebang-regex": "^3.0.0"
+          }
+        },
+        "shebang-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+          "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "7.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
+          "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        },
+        "to-regex-range": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+          "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+          "dev": true,
+          "requires": {
+            "is-number": "^7.0.0"
+          }
+        },
+        "which": {
+          "version": "2.0.2",
+          "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+          "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+          "dev": true,
+          "requires": {
+            "isexe": "^2.0.0"
+          }
+        }
+      }
+    },
+    "listr2": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/listr2/-/listr2-1.3.8.tgz",
+      "integrity": "sha512-iRDRVTgSDz44tBeBBg/35TQz4W+EZBWsDUq7hPpqeUHm7yLPNll0rkwW3lIX9cPAK7l+x95mGWLpxjqxftNfZA==",
+      "dev": true,
+      "requires": {
+        "@samverschueren/stream-to-observable": "^0.3.0",
+        "chalk": "^3.0.0",
+        "cli-cursor": "^3.1.0",
+        "cli-truncate": "^2.1.0",
+        "elegant-spinner": "^2.0.0",
+        "enquirer": "^2.3.4",
+        "figures": "^3.2.0",
+        "indent-string": "^4.0.0",
+        "log-update": "^4.0.0",
+        "p-map": "^4.0.0",
+        "pad": "^3.2.0",
+        "rxjs": "^6.3.3",
+        "through": "^2.3.8",
+        "uuid": "^7.0.2"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+          "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+          "dev": true,
+          "requires": {
+            "@types/color-name": "^1.1.1",
+            "color-convert": "^2.0.1"
+          }
+        },
+        "chalk": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+          "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "p-map": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+          "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+          "dev": true,
+          "requires": {
+            "aggregate-error": "^3.0.0"
+          }
+        },
+        "supports-color": {
+          "version": "7.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
+          "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        },
+        "uuid": {
+          "version": "7.0.3",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
+          "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
+          "dev": true
+        }
+      }
+    },
     "load-json-file": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
@@ -9562,6 +10019,97 @@
       "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
       "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
     },
+    "log-symbols": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz",
+      "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==",
+      "dev": true,
+      "requires": {
+        "chalk": "^2.4.2"
+      }
+    },
+    "log-update": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
+      "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
+      "dev": true,
+      "requires": {
+        "ansi-escapes": "^4.3.0",
+        "cli-cursor": "^3.1.0",
+        "slice-ansi": "^4.0.0",
+        "wrap-ansi": "^6.2.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+          "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+          "dev": true
+        },
+        "ansi-styles": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+          "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+          "dev": true,
+          "requires": {
+            "@types/color-name": "^1.1.1",
+            "color-convert": "^2.0.1"
+          }
+        },
+        "astral-regex": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+          "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+          "dev": true
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "slice-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+          "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "astral-regex": "^2.0.0",
+            "is-fullwidth-code-point": "^3.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+          "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^5.0.0"
+          }
+        },
+        "wrap-ansi": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+          "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          }
+        }
+      }
+    },
     "loglevel": {
       "version": "1.6.6",
       "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.6.tgz",
@@ -10959,6 +11507,15 @@
         "semver": "^6.2.0"
       }
     },
+    "pad": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/pad/-/pad-3.2.0.tgz",
+      "integrity": "sha512-2u0TrjcGbOjBTJpyewEl4hBO3OeX5wWue7eIFPzQTg6wFSvoaHcBTTUY5m+n0hd04gmTCPuY0kCpVIVuw5etwg==",
+      "dev": true,
+      "requires": {
+        "wcwidth": "^1.0.1"
+      }
+    },
     "pako": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz",
@@ -11128,6 +11685,12 @@
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
       "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
     },
+    "picomatch": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
+      "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
+      "dev": true
+    },
     "pify": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
@@ -14630,6 +15193,12 @@
       "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
       "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
     },
+    "string-argv": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
+      "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
+      "dev": true
+    },
     "string-length": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz",
@@ -14930,6 +15499,12 @@
       "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
       "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
     },
+    "strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "dev": true
+    },
     "strip-indent": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -15833,6 +16408,15 @@
         "minimalistic-assert": "^1.0.0"
       }
     },
+    "wcwidth": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+      "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
+      "dev": true,
+      "requires": {
+        "defaults": "^1.0.3"
+      }
+    },
     "weak-napi": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/weak-napi/-/weak-napi-1.0.3.tgz",

+ 6 - 1
kafka-ui-react-app/src/components/ConsumerGroups/ConsumerGroups.tsx

@@ -2,7 +2,8 @@ import React from 'react';
 import { ClusterName } from 'redux/interfaces';
 import { Switch, Route } from 'react-router-dom';
 import PageLoader from 'components/common/PageLoader/PageLoader';
-import ListContainer from './List/ListContainer';
+import DetailsContainer from 'components/ConsumerGroups/Details/DetailsContainer';
+import ListContainer from 'components/ConsumerGroups/List/ListContainer';
 
 interface Props {
   clusterName: ClusterName;
@@ -27,6 +28,10 @@ const ConsumerGroups: React.FC<Props> = ({
           path="/ui/clusters/:clusterName/consumer-groups"
           component={ListContainer}
         />
+        <Route
+          path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID"
+          component={DetailsContainer}
+        />
       </Switch>
     );
   }

+ 86 - 0
kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx

@@ -0,0 +1,86 @@
+import React from 'react';
+import { ClusterName } from 'redux/interfaces';
+import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import { clusterConsumerGroupsPath } from 'lib/paths';
+import {
+  ConsumerGroupID,
+  ConsumerGroup,
+  ConsumerGroupDetails,
+  Consumer,
+} from 'redux/interfaces/consumerGroup';
+
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import ListItem from './ListItem';
+
+interface Props extends ConsumerGroup, ConsumerGroupDetails {
+  clusterName: ClusterName;
+  consumerGroupID: ConsumerGroupID;
+  consumers: Consumer[];
+  isFetched: boolean;
+  fetchConsumerGroupDetails: (
+    clusterName: ClusterName,
+    consumerGroupID: ConsumerGroupID
+  ) => void;
+}
+
+const Details: React.FC<Props> = ({
+  clusterName,
+  consumerGroupID,
+  consumers,
+  isFetched,
+  fetchConsumerGroupDetails,
+}) => {
+  React.useEffect(() => {
+    fetchConsumerGroupDetails(clusterName, consumerGroupID);
+  }, [fetchConsumerGroupDetails, clusterName, consumerGroupID]);
+  const items = consumers || [];
+
+  return (
+    <div className="section">
+      <div className="level">
+        <div className="level-item level-left">
+          <Breadcrumb
+            links={[
+              {
+                href: clusterConsumerGroupsPath(clusterName),
+                label: 'All Consumer Groups',
+              },
+            ]}
+          >
+            {consumerGroupID}
+          </Breadcrumb>
+        </div>
+      </div>
+
+      {isFetched ? (
+        <div className="box">
+          <table className="table is-striped is-fullwidth">
+            <thead>
+              <tr>
+                <th>Consumer ID</th>
+                <th>Topic</th>
+                <th>Partition</th>
+                <th>Messages behind</th>
+                <th>Current offset</th>
+                <th>End offset</th>
+              </tr>
+            </thead>
+            <tbody>
+              {items.map((consumer) => (
+                <ListItem
+                  key={consumer.consumerId}
+                  clusterName={clusterName}
+                  consumer={consumer}
+                />
+              ))}
+            </tbody>
+          </table>
+        </div>
+      ) : (
+        <PageLoader />
+      )}
+    </div>
+  );
+};
+
+export default Details;

+ 29 - 0
kafka-ui-react-app/src/components/ConsumerGroups/Details/DetailsContainer.ts

@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+import Details from './Details';
+import {ClusterName, RootState} from 'redux/interfaces';
+import { withRouter, RouteComponentProps } from 'react-router-dom';
+import { getIsConsumerGroupDetailsFetched, getConsumerGroupByID } from 'redux/reducers/consumerGroups/selectors';
+import { ConsumerGroupID } from 'redux/interfaces/consumerGroup';
+import { fetchConsumerGroupDetails } from 'redux/actions/thunks';
+
+interface RouteProps {
+  clusterName: ClusterName;
+  consumerGroupID: string;
+}
+
+interface OwnProps extends RouteComponentProps<RouteProps> { }
+
+const mapStateToProps = (state: RootState, { match: { params: { consumerGroupID, clusterName } } }: OwnProps) => ({
+  clusterName,
+  consumerGroupID,
+  isFetched: getIsConsumerGroupDetailsFetched(state),
+  ...getConsumerGroupByID(state, consumerGroupID)
+});
+
+const mapDispatchToProps = {
+  fetchConsumerGroupDetails: (clusterName: ClusterName, consumerGroupID: ConsumerGroupID) => fetchConsumerGroupDetails(clusterName, consumerGroupID),
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(Details)
+);

+ 33 - 0
kafka-ui-react-app/src/components/ConsumerGroups/Details/ListItem.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { Consumer } from 'redux/interfaces/consumerGroup';
+import { NavLink } from 'react-router-dom';
+import { ClusterName } from 'redux/interfaces/cluster';
+
+interface Props {
+  clusterName: ClusterName;
+  consumer: Consumer;
+}
+
+const ListItem: React.FC<Props> = ({ clusterName, consumer }) => {
+  return (
+    <tr>
+      <td>{consumer.consumerId}</td>
+      <td>
+        <NavLink
+          exact
+          to={`/clusters/${clusterName}/topics/${consumer.topic}`}
+          activeClassName="is-active"
+          className="title is-6"
+        >
+          {consumer.topic}
+        </NavLink>
+      </td>
+      <td>{consumer.partition}</td>
+      <td>{consumer.messagesBehind}</td>
+      <td>{consumer.currentOffset}</td>
+      <td>{consumer.endOffset}</td>
+    </tr>
+  );
+};
+
+export default ListItem;

+ 39 - 33
kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx

@@ -1,33 +1,31 @@
-import React, { ChangeEvent } from 'react';
-import { ConsumerGroup, ClusterName } from 'redux/interfaces';
-import ListItem from './ListItem';
+import React from 'react';
+import { ClusterName, ConsumerGroup } from 'redux/interfaces';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import ListItem from './ListItem';
 
 interface Props {
   clusterName: ClusterName;
-  consumerGroups: (ConsumerGroup)[];
+  consumerGroups: ConsumerGroup[];
 }
 
-const List: React.FC<Props> = ({
-  consumerGroups,
-}) => {
-
+const List: React.FC<Props> = ({ consumerGroups }) => {
   const [searchText, setSearchText] = React.useState<string>('');
 
   const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
     setSearchText(event.target.value);
   };
 
-  const items = consumerGroups;
-
   return (
     <div className="section">
       <Breadcrumb>All Consumer Groups</Breadcrumb>
 
       <div className="box">
-        <div className="columns">
-          <div className="column is-half is-offset-half">
-            <input  id="searchText"
+        {consumerGroups.length > 0 ? (
+          <div>
+            <div className="columns">
+              <div className="column is-half is-offset-half">
+                <input
+                  id="searchText"
                   type="text"
                   name="searchText"
                   className="input"
@@ -35,27 +33,35 @@ const List: React.FC<Props> = ({
                   value={searchText}
                   onChange={handleInputChange}
                 />
+              </div>
+            </div>
+            <table className="table is-striped is-fullwidth is-hoverable">
+              <thead>
+                <tr>
+                  <th>Consumer group ID</th>
+                  <th>Num of consumers</th>
+                  <th>Num of topics</th>
+                </tr>
+              </thead>
+              <tbody>
+                {consumerGroups
+                  .filter(
+                    (consumerGroup) =>
+                      !searchText ||
+                      consumerGroup?.consumerGroupId?.indexOf(searchText) >= 0
+                  )
+                  .map((consumerGroup) => (
+                    <ListItem
+                      key={consumerGroup.consumerGroupId}
+                      consumerGroup={consumerGroup}
+                    />
+                  ))}
+              </tbody>
+            </table>
           </div>
-        </div>
-        <table className="table is-striped is-fullwidth">
-          <thead>
-            <tr>
-              <th>Consumer group ID</th>
-              <th>Num of consumers</th>
-              <th>Num of topics</th>
-            </tr>
-          </thead>
-          <tbody>
-            {items
-              .filter( (consumerGroup) => !searchText || consumerGroup?.consumerGroupId?.indexOf(searchText) >= 0)
-              .map((consumerGroup, index) => (
-                <ListItem
-                  key={`consumer-group-list-item-key-${index}`}
-                  {...consumerGroup}
-                />
-            ))}
-          </tbody>
-        </table>
+        ) : (
+          'No active consumer groups'
+        )}
       </div>
     </div>
   );

+ 14 - 15
kafka-ui-react-app/src/components/ConsumerGroups/List/ListItem.tsx

@@ -1,24 +1,23 @@
 import React from 'react';
-import { NavLink } from 'react-router-dom';
+import { useHistory } from 'react-router-dom';
 import { ConsumerGroup } from 'redux/interfaces';
 
-const ListItem: React.FC<ConsumerGroup> = ({
-  consumerGroupId,
-  numConsumers,
-  numTopics,
+const ListItem: React.FC<{ consumerGroup: ConsumerGroup }> = ({
+  consumerGroup,
 }) => {
+  const history = useHistory();
+
+  function goToConsumerGroupDetails() {
+    history.push(`consumer-groups/${consumerGroup.consumerGroupId}`);
+  }
+
   return (
-    <tr>
-      {/* <td>
-        <NavLink exact to={`consumer-groups/${consumerGroupId}`} activeClassName="is-active" className="title is-6">
-          {consumerGroupId}
-        </NavLink>
-      </td> */}
-      <td>{consumerGroupId}</td>
-      <td>{numConsumers}</td>
-      <td>{numTopics}</td>
+    <tr className="cursor-pointer" onClick={goToConsumerGroupDetails}>
+      <td>{consumerGroup.consumerGroupId}</td>
+      <td>{consumerGroup.numConsumers}</td>
+      <td>{consumerGroup.numTopics}</td>
     </tr>
   );
-}
+};
 
 export default ListItem;

+ 38 - 14
kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx

@@ -1,9 +1,15 @@
 import React, { CSSProperties } from 'react';
 import { Cluster } from 'redux/interfaces';
 import { NavLink } from 'react-router-dom';
-import { clusterBrokersPath, clusterTopicsPath, clusterConsumerGroupsPath } from 'lib/paths';
+import {
+  clusterBrokersPath,
+  clusterTopicsPath,
+  clusterConsumerGroupsPath,
+} from 'lib/paths';
 
-interface Props extends Cluster {}
+interface Props {
+  cluster: Cluster;
+}
 
 const DefaultIcon: React.FC = () => {
   const style: CSSProperties = {
@@ -15,29 +21,47 @@ const DefaultIcon: React.FC = () => {
 
   return (
     <span title="Default Cluster" className="icon has-text-primary is-small">
-      <i style={style} data-fa-transform="rotate-340" className="fas fa-thumbtack" />
+      <i
+        style={style}
+        data-fa-transform="rotate-340"
+        className="fas fa-thumbtack"
+      />
     </span>
-  )
+  );
 };
 
-const ClusterMenu: React.FC<Props> = ({
-  name,
-  defaultCluster,
-}) => (
+const ClusterMenu: React.FC<Props> = ({ cluster }) => (
   <ul className="menu-list">
     <li>
-      <NavLink exact to={clusterBrokersPath(name)} title={name} className="has-text-overflow-ellipsis">
-        {defaultCluster && <DefaultIcon />}
-        {name}
+      <NavLink
+        exact
+        to={clusterBrokersPath(cluster.name)}
+        title={cluster.name}
+        className="has-text-overflow-ellipsis"
+      >
+        {cluster.defaultCluster && <DefaultIcon />}
+        {cluster.name}
       </NavLink>
       <ul>
-        <NavLink to={clusterBrokersPath(name)} activeClassName="is-active" title="Brokers">
+        <NavLink
+          to={clusterBrokersPath(cluster.name)}
+          activeClassName="is-active"
+          title="Brokers"
+        >
           Brokers
         </NavLink>
-        <NavLink to={clusterTopicsPath(name)} activeClassName="is-active" title="Topics">
+        <NavLink
+          to={clusterTopicsPath(cluster.name)}
+          activeClassName="is-active"
+          title="Topics"
+        >
           Topics
         </NavLink>
-        <NavLink to={clusterConsumerGroupsPath(name)} activeClassName="is-active" title="Consumers">
+        <NavLink
+          to={clusterConsumerGroupsPath(cluster.name)}
+          activeClassName="is-active"
+          title="Consumers"
+        >
           Consumers
         </NavLink>
       </ul>

+ 2 - 2
kafka-ui-react-app/src/components/Nav/Nav.tsx

@@ -28,8 +28,8 @@ const Nav: React.FC<Props> = ({
     {!isClusterListFetched && <div className="loader" />}
 
     {isClusterListFetched &&
-      clusters.map((cluster, index) => (
-        <ClusterMenu {...cluster} key={`cluster-list-item-key-${index}`} />
+      clusters.map((cluster) => (
+        <ClusterMenu cluster={cluster} key={cluster.id} />
       ))}
   </aside>
 );

+ 7 - 6
kafka-ui-react-app/src/components/Topics/New/New.tsx

@@ -37,12 +37,12 @@ const New: React.FC<Props> = ({
       redirectToTopicPath(clusterName, name);
     }
   }, [
-      isSubmitting,
-      isTopicCreated,
-      redirectToTopicPath,
-      clusterName,
-      methods.getValues,
-    ]);
+    isSubmitting,
+    isTopicCreated,
+    redirectToTopicPath,
+    clusterName,
+    methods.getValues,
+  ]);
 
   const onSubmit = async (data: TopicFormData) => {
     // TODO: need to fix loader. After success loading the first time, we won't wait for creation any more, because state is
@@ -68,6 +68,7 @@ const New: React.FC<Props> = ({
       </div>
 
       <div className="box">
+        {/* eslint-disable react/jsx-props-no-spreading */}
         <FormContext {...methods}>
           <form onSubmit={methods.handleSubmit(onSubmit)}>
             <div className="columns">

+ 4 - 0
kafka-ui-react-app/src/redux/actionType.ts

@@ -30,4 +30,8 @@ export enum ActionType {
   GET_CONSUMER_GROUPS__REQUEST = 'GET_CONSUMER_GROUPS__REQUEST',
   GET_CONSUMER_GROUPS__SUCCESS = 'GET_CONSUMER_GROUPS__SUCCESS',
   GET_CONSUMER_GROUPS__FAILURE = 'GET_CONSUMER_GROUPS__FAILURE',
+
+  GET_CONSUMER_GROUP_DETAILS__REQUEST = 'GET_CONSUMER_GROUP_DETAILS__REQUEST',
+  GET_CONSUMER_GROUP_DETAILS__SUCCESS = 'GET_CONSUMER_GROUP_DETAILS__SUCCESS',
+  GET_CONSUMER_GROUP_DETAILS__FAILURE = 'GET_CONSUMER_GROUP_DETAILS__FAILURE',
 };

+ 7 - 1
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -1,6 +1,6 @@
 import { createAsyncAction } from 'typesafe-actions';
 import { ActionType } from 'redux/actionType';
-import { ConsumerGroup } from '../interfaces/consumerGroup';
+import { ConsumerGroup, ConsumerGroupID, ConsumerGroupDetails } from '../interfaces/consumerGroup';
 import {
   Broker,
   BrokerMetrics,
@@ -58,3 +58,9 @@ export const fetchConsumerGroupsAction = createAsyncAction(
   ActionType.GET_CONSUMER_GROUPS__SUCCESS,
   ActionType.GET_CONSUMER_GROUPS__FAILURE,
 )<undefined, ConsumerGroup[], undefined>();
+
+export const fetchConsumerGroupDetailsAction = createAsyncAction(
+  ActionType.GET_CONSUMER_GROUP_DETAILS__REQUEST,
+  ActionType.GET_CONSUMER_GROUP_DETAILS__SUCCESS,
+  ActionType.GET_CONSUMER_GROUP_DETAILS__FAILURE,
+)<undefined, { consumerGroupID: ConsumerGroupID, details: ConsumerGroupDetails }, undefined>();

+ 11 - 0
kafka-ui-react-app/src/redux/actions/thunks.ts

@@ -1,5 +1,6 @@
 import * as api from 'redux/api';
 import * as actions from './actions';
+import { ConsumerGroupID } from '../interfaces/consumerGroup';
 import {
   PromiseThunk,
   Cluster,
@@ -87,3 +88,13 @@ export const fetchConsumerGroupsList = (clusterName: ClusterName): PromiseThunk<
     dispatch(actions.fetchConsumerGroupsAction.failure());
   }
 };
+
+export const fetchConsumerGroupDetails = (clusterName: ClusterName, consumerGroupID: ConsumerGroupID): PromiseThunk<void> => async (dispatch) => {
+  dispatch(actions.fetchConsumerGroupDetailsAction.request());
+  try {
+    const consumerGroupDetails = await api.getConsumerGroupDetails(clusterName, consumerGroupID);
+    dispatch(actions.fetchConsumerGroupDetailsAction.success({ consumerGroupID, details: consumerGroupDetails }));
+  } catch (e) {
+    dispatch(actions.fetchConsumerGroupDetailsAction.failure());
+  }
+};

+ 5 - 1
kafka-ui-react-app/src/redux/api/consumerGroups.ts

@@ -1,8 +1,12 @@
 import { ClusterName } from '../interfaces/cluster';
-import { ConsumerGroup } from '../interfaces/consumerGroup';
+import { ConsumerGroup, ConsumerGroupID, ConsumerGroupDetails } from '../interfaces/consumerGroup';
 import { BASE_PARAMS, BASE_URL } from '../../lib/constants';
 
 
 export const getConsumerGroups = (clusterName: ClusterName): Promise<ConsumerGroup[]> =>
   fetch(`${BASE_URL}/clusters/${clusterName}/consumerGroups`, { ...BASE_PARAMS })
     .then(res => res.json());
+
+export const getConsumerGroupDetails = (clusterName: ClusterName, consumerGroupID: ConsumerGroupID): Promise<ConsumerGroupDetails> =>
+  fetch(`${BASE_URL}/clusters/${clusterName}/consumer-groups/${consumerGroupID}`, { ...BASE_PARAMS })
+    .then(res => res.json());

+ 5 - 5
kafka-ui-react-app/src/redux/api/topics.ts

@@ -1,14 +1,14 @@
 import { reduce } from 'lodash';
 import {
-  TopicName,
-  Topic,
   ClusterName,
-  TopicDetails,
+  Topic,
   TopicConfig,
-  TopicFormData,
+  TopicDetails,
   TopicFormCustomParam,
+  TopicFormData,
+  TopicName,
 } from 'redux/interfaces';
-import { BASE_URL, BASE_PARAMS } from 'lib/constants';
+import { BASE_PARAMS, BASE_URL } from 'lib/constants';
 
 export const getTopicConfig = (
   clusterName: ClusterName,

+ 27 - 1
kafka-ui-react-app/src/redux/interfaces/consumerGroup.ts

@@ -1,5 +1,31 @@
+export type ConsumerGroupID = string;
+
 export interface ConsumerGroup {
-  consumerGroupId: string;
+  consumerGroupId: ConsumerGroupID;
   numConsumers: number;
   numTopics: number;
+}
+
+export interface ConsumerGroupDetails {
+  consumerGroupId: ConsumerGroupID;
+  numConsumers: number;
+  numTopics: number;
+  consumers: Consumer[];
+}
+
+export interface Consumer {
+  consumerId: string;
+  topic: string;
+  partition: number;
+  messagesBehind: number;
+  currentOffset: number;
+  endOffset: number;
+}
+
+export interface ConsumerGroupDetailedInfo extends ConsumerGroup, ConsumerGroupDetails {
+}
+
+export interface ConsumerGroupsState {
+  byID: { [consumerGroupID: string]: ConsumerGroupDetailedInfo },
+  allIDs: string[]
 }

+ 2 - 2
kafka-ui-react-app/src/redux/interfaces/index.ts

@@ -8,7 +8,7 @@ import { TopicsState } from './topic';
 import { Cluster } from './cluster';
 import { BrokersState } from './broker';
 import { LoaderState } from './loader';
-import { ConsumerGroup } from './consumerGroup';
+import { ConsumerGroupsState } from './consumerGroup';
 
 export * from './topic';
 export * from './cluster';
@@ -27,7 +27,7 @@ export interface RootState {
   topics: TopicsState;
   clusters: Cluster[];
   brokers: BrokersState;
-  consumerGroups: ConsumerGroup[];
+  consumerGroups: ConsumerGroupsState;
   loader: LoaderState;
 }
 

+ 1 - 1
kafka-ui-react-app/src/redux/interfaces/topic.ts

@@ -75,4 +75,4 @@ export interface TopicFormData {
   customParams: {
     [index: string]: TopicFormCustomParam;
   };
-};
+}

+ 33 - 11
kafka-ui-react-app/src/redux/reducers/brokers/selectors.ts

@@ -8,16 +8,38 @@ const getBrokerListFetchingStatus = createFetchingSelector('GET_BROKERS');
 
 export const getIsBrokerListFetched = createSelector(
   getBrokerListFetchingStatus,
-  (status) => status === FetchStatus.fetched,
+  (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 getOnlinePartitionCount = createSelector(brokersState, ({ onlinePartitionCount }) => onlinePartitionCount);
-export const getOfflinePartitionCount = createSelector(brokersState, ({ offlinePartitionCount }) => offlinePartitionCount);
-export const getInSyncReplicasCount = createSelector(brokersState, ({ inSyncReplicasCount }) => inSyncReplicasCount);
-export const getOutOfSyncReplicasCount = createSelector(brokersState, ({ outOfSyncReplicasCount }) => outOfSyncReplicasCount);
-export const getUnderReplicatedPartitionCount = createSelector(brokersState, ({ underReplicatedPartitionCount }) => underReplicatedPartitionCount);
+export const getBrokerCount = createSelector(
+  brokersState,
+  ({ brokerCount }) => brokerCount
+);
+export const getZooKeeperStatus = createSelector(
+  brokersState,
+  ({ zooKeeperStatus }) => zooKeeperStatus
+);
+export const getActiveControllers = createSelector(
+  brokersState,
+  ({ activeControllers }) => activeControllers
+);
+export const getOnlinePartitionCount = createSelector(
+  brokersState,
+  ({ onlinePartitionCount }) => onlinePartitionCount
+);
+export const getOfflinePartitionCount = createSelector(
+  brokersState,
+  ({ offlinePartitionCount }) => offlinePartitionCount
+);
+export const getInSyncReplicasCount = createSelector(
+  brokersState,
+  ({ inSyncReplicasCount }) => inSyncReplicasCount
+);
+export const getOutOfSyncReplicasCount = createSelector(
+  brokersState,
+  ({ outOfSyncReplicasCount }) => outOfSyncReplicasCount
+);
+export const getUnderReplicatedPartitionCount = createSelector(
+  brokersState,
+  ({ underReplicatedPartitionCount }) => underReplicatedPartitionCount
+);

+ 39 - 3
kafka-ui-react-app/src/redux/reducers/consumerGroups/reducer.ts

@@ -1,12 +1,48 @@
 import { Action, ConsumerGroup } from 'redux/interfaces';
 import { ActionType } from 'redux/actionType';
+import { ConsumerGroupsState } from '../../interfaces/consumerGroup';
 
-export const initialState: ConsumerGroup[] = [];
+export const initialState: ConsumerGroupsState = {
+  byID: {},
+  allIDs: []
+};
+
+const updateConsumerGroupsList = (state: ConsumerGroupsState, payload: ConsumerGroup[]): ConsumerGroupsState => {
+  const initialMemo: ConsumerGroupsState = {
+    ...state,
+    allIDs: []
+  };
+
+  return payload.reduce(
+    (memo: ConsumerGroupsState, consumerGroup) => {
+      const {consumerGroupId} = consumerGroup;
+      memo.byID[consumerGroupId] = {
+        ...memo.byID[consumerGroupId],
+        ...consumerGroup,
+      };
+      memo.allIDs.push(consumerGroupId);
+
+      return memo;
+    },
+    initialMemo,
+  );
+};
 
-const reducer = (state = initialState, action: Action): ConsumerGroup[] => {
+const reducer = (state = initialState, action: Action): ConsumerGroupsState => {
   switch (action.type) {
     case ActionType.GET_CONSUMER_GROUPS__SUCCESS:
-      return action.payload;
+      return updateConsumerGroupsList(state, action.payload);
+    case ActionType.GET_CONSUMER_GROUP_DETAILS__SUCCESS:
+      return {
+        ...state,
+        byID: {
+          ...state.byID,
+          [action.payload.consumerGroupID]: {
+            ...state.byID[action.payload.consumerGroupID],
+            ...action.payload.details,
+          }
+        }
+      };
     default:
       return state;
   }

+ 32 - 3
kafka-ui-react-app/src/redux/reducers/consumerGroups/selectors.ts

@@ -1,15 +1,44 @@
 import { createSelector } from 'reselect';
-import { ConsumerGroup, RootState, FetchStatus } from 'redux/interfaces';
+import { RootState, FetchStatus } from 'redux/interfaces';
 import { createFetchingSelector } from 'redux/reducers/loader/selectors';
+import { ConsumerGroupID, ConsumerGroupsState } from '../../interfaces/consumerGroup';
 
 
-const consumerGroupsState = ({ consumerGroups }: RootState): ConsumerGroup[] => consumerGroups;
+const consumerGroupsState = ({ consumerGroups }: RootState): ConsumerGroupsState => consumerGroups;
+
+const getConsumerGroupsMap = (state: RootState) => consumerGroupsState(state).byID;
+const getConsumerGroupsIDsList = (state: RootState) => consumerGroupsState(state).allIDs;
 
 const getConsumerGroupsListFetchingStatus = createFetchingSelector('GET_CONSUMER_GROUPS');
+const getConsumerGroupDetailsFetchingStatus = createFetchingSelector('GET_CONSUMER_GROUP_DETAILS');
 
 export const getIsConsumerGroupsListFetched = createSelector(
   getConsumerGroupsListFetchingStatus,
   (status) => status === FetchStatus.fetched,
 );
 
-export const getConsumerGroupsList = createSelector(consumerGroupsState, (consumerGroups) => consumerGroups);
+export const getIsConsumerGroupDetailsFetched = createSelector(
+  getConsumerGroupDetailsFetchingStatus,
+  (status) => status === FetchStatus.fetched,
+);
+
+export const getConsumerGroupsList = createSelector(
+  getIsConsumerGroupsListFetched,
+  getConsumerGroupsMap,
+  getConsumerGroupsIDsList,
+  (isFetched, byID, ids) => {
+    if (!isFetched) {
+      return [];
+    }
+
+    return ids.map(key => byID[key]);
+  },
+);
+
+const getConsumerGroupID = (_: RootState, consumerGroupID: ConsumerGroupID) => consumerGroupID;
+
+export const getConsumerGroupByID = createSelector(
+  getConsumerGroupsMap,
+  getConsumerGroupID,
+  (consumerGroups, consumerGroupID) => consumerGroups[consumerGroupID],
+);

+ 4 - 0
kafka-ui-react-app/src/theme/index.scss

@@ -31,3 +31,7 @@ code {
   font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
     monospace;
 }
+
+.cursor-pointer {
+  cursor: pointer;
+}

+ 1 - 0
pom.xml

@@ -27,6 +27,7 @@
 		<openapi-generator-maven-plugin.version>4.2.2</openapi-generator-maven-plugin.version>
 		<swagger-annotations.version>1.6.0</swagger-annotations.version>
 		<springdoc-openapi-webflux-ui.version>1.2.32</springdoc-openapi-webflux-ui.version>
+		<kafka.version>2.4.1</kafka.version>
 	</properties>
 
 	<groupId>com.provectus</groupId>