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>
This commit is contained in:
soffest 2020-05-21 14:09:59 +03:00 committed by GitHub
parent a0b4b6e1f0
commit 0815739d79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1156 additions and 110 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"]
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
{consumerGroups.length > 0 ? (
<div>
<div className="columns">
<div className="column is-half is-offset-half">
<input id="searchText"
<input
id="searchText"
type="text"
name="searchText"
className="input"
@ -37,7 +35,7 @@ const List: React.FC<Props> = ({
/>
</div>
</div>
<table className="table is-striped is-fullwidth">
<table className="table is-striped is-fullwidth is-hoverable">
<thead>
<tr>
<th>Consumer group ID</th>
@ -46,17 +44,25 @@ const List: React.FC<Props> = ({
</tr>
</thead>
<tbody>
{items
.filter( (consumerGroup) => !searchText || consumerGroup?.consumerGroupId?.indexOf(searchText) >= 0)
.map((consumerGroup, index) => (
{consumerGroups
.filter(
(consumerGroup) =>
!searchText ||
consumerGroup?.consumerGroupId?.indexOf(searchText) >= 0
)
.map((consumerGroup) => (
<ListItem
key={`consumer-group-list-item-key-${index}`}
{...consumerGroup}
key={consumerGroup.consumerGroupId}
consumerGroup={consumerGroup}
/>
))}
</tbody>
</table>
</div>
) : (
'No active consumer groups'
)}
</div>
</div>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 reducer = (state = initialState, action: Action): ConsumerGroup[] => {
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): 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;
}

View file

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

View file

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

View file

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