Browse Source

Brokers API improvements (#2743)

* Brokers API improvements:
1. broker io rates stats added
2. active controller property set to node id
3. minor refactoring
4. FE: Add an indicator for an active broker controller

Co-authored-by: Mgrdich <mgotm13@gmail.com>
Co-authored-by: iliax <ikuramshin@provectus.com>
Co-authored-by: Hrant Abrahamyan <113341474+habrahamyanpro@users.noreply.github.com>
Co-authored-by: Mgrdich <mgotm13@gmail.com>
Ilya Kuramshin 2 years ago
parent
commit
f9906b5d30
17 changed files with 256 additions and 99 deletions
  1. 2 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java
  2. 4 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/mapper/ClusterMapper.java
  3. 24 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalBroker.java
  4. 10 5
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalClusterState.java
  5. 2 2
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalTopic.java
  6. 9 4
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/Metrics.java
  7. 4 9
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java
  8. 17 22
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/ReactiveAdminClient.java
  9. 1 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/metrics/MetricsCollector.java
  10. 29 4
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/metrics/WellKnownMetrics.java
  11. 3 7
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/BrokerServiceTest.java
  12. 61 34
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/metrics/WellKnownMetricsTest.java
  13. 5 0
      kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
  14. 16 0
      kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.styled.ts
  15. 38 10
      kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx
  16. 22 0
      kafka-ui-react-app/src/components/common/Icons/CheckMarkRoundIcon.tsx
  17. 9 0
      kafka-ui-react-app/src/components/common/Icons/StarIcon.tsx

+ 2 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java

@@ -37,7 +37,8 @@ public class BrokersController extends AbstractController implements BrokersApi
   @Override
   @Override
   public Mono<ResponseEntity<Flux<BrokerDTO>>> getBrokers(String clusterName,
   public Mono<ResponseEntity<Flux<BrokerDTO>>> getBrokers(String clusterName,
                                                           ServerWebExchange exchange) {
                                                           ServerWebExchange exchange) {
-    return Mono.just(ResponseEntity.ok(brokerService.getBrokers(getCluster(clusterName))));
+    return Mono.just(ResponseEntity.ok(
+        brokerService.getBrokers(getCluster(clusterName)).map(clusterMapper::toBrokerDto)));
   }
   }
 
 
   @Override
   @Override

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

@@ -2,6 +2,7 @@ package com.provectus.kafka.ui.mapper;
 
 
 import com.provectus.kafka.ui.config.ClustersProperties;
 import com.provectus.kafka.ui.config.ClustersProperties;
 import com.provectus.kafka.ui.model.BrokerConfigDTO;
 import com.provectus.kafka.ui.model.BrokerConfigDTO;
+import com.provectus.kafka.ui.model.BrokerDTO;
 import com.provectus.kafka.ui.model.BrokerDiskUsageDTO;
 import com.provectus.kafka.ui.model.BrokerDiskUsageDTO;
 import com.provectus.kafka.ui.model.BrokerMetricsDTO;
 import com.provectus.kafka.ui.model.BrokerMetricsDTO;
 import com.provectus.kafka.ui.model.ClusterDTO;
 import com.provectus.kafka.ui.model.ClusterDTO;
@@ -14,6 +15,7 @@ import com.provectus.kafka.ui.model.ConfigSynonymDTO;
 import com.provectus.kafka.ui.model.ConnectDTO;
 import com.provectus.kafka.ui.model.ConnectDTO;
 import com.provectus.kafka.ui.model.FailoverUrlList;
 import com.provectus.kafka.ui.model.FailoverUrlList;
 import com.provectus.kafka.ui.model.Feature;
 import com.provectus.kafka.ui.model.Feature;
+import com.provectus.kafka.ui.model.InternalBroker;
 import com.provectus.kafka.ui.model.InternalBrokerConfig;
 import com.provectus.kafka.ui.model.InternalBrokerConfig;
 import com.provectus.kafka.ui.model.InternalBrokerDiskUsage;
 import com.provectus.kafka.ui.model.InternalBrokerDiskUsage;
 import com.provectus.kafka.ui.model.InternalClusterState;
 import com.provectus.kafka.ui.model.InternalClusterState;
@@ -103,6 +105,8 @@ public interface ClusterMapper {
 
 
   PartitionDTO toPartition(InternalPartition topic);
   PartitionDTO toPartition(InternalPartition topic);
 
 
+  BrokerDTO toBrokerDto(InternalBroker broker);
+
   @Named("setSchemaRegistry")
   @Named("setSchemaRegistry")
   default InternalSchemaRegistry setSchemaRegistry(ClustersProperties.Cluster clusterProperties) {
   default InternalSchemaRegistry setSchemaRegistry(ClustersProperties.Cluster clusterProperties) {
     if (clusterProperties == null
     if (clusterProperties == null

+ 24 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalBroker.java

@@ -0,0 +1,24 @@
+package com.provectus.kafka.ui.model;
+
+import java.math.BigDecimal;
+import lombok.Data;
+import org.apache.kafka.common.Node;
+
+@Data
+public class InternalBroker {
+
+  private final Integer id;
+  private final String host;
+  private final Integer port;
+  private final BigDecimal bytesInPerSec;
+  private final BigDecimal bytesOutPerSec;
+
+  public InternalBroker(Node node, Statistics statistics) {
+    this.id = node.id();
+    this.host = node.host();
+    this.port = node.port();
+    this.bytesInPerSec = statistics.getMetrics().getBrokerBytesInPerSec().get(node.id());
+    this.bytesOutPerSec = statistics.getMetrics().getBrokerBytesOutPerSec().get(node.id());
+  }
+
+}

+ 10 - 5
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalClusterState.java

@@ -6,6 +6,7 @@ import java.util.List;
 import java.util.Optional;
 import java.util.Optional;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import lombok.Data;
 import lombok.Data;
+import org.apache.kafka.common.Node;
 
 
 @Data
 @Data
 public class InternalClusterState {
 public class InternalClusterState {
@@ -37,7 +38,9 @@ public class InternalClusterState {
         .orElse(null);
         .orElse(null);
     topicCount = statistics.getTopicDescriptions().size();
     topicCount = statistics.getTopicDescriptions().size();
     brokerCount = statistics.getClusterDescription().getNodes().size();
     brokerCount = statistics.getClusterDescription().getNodes().size();
-    activeControllers = statistics.getClusterDescription().getController() != null ? 1 : 0;
+    activeControllers = Optional.ofNullable(statistics.getClusterDescription().getController())
+        .map(Node::id)
+        .orElse(null);
     version = statistics.getVersion();
     version = statistics.getVersion();
 
 
     if (statistics.getLogDirInfo() != null) {
     if (statistics.getLogDirInfo() != null) {
@@ -53,15 +56,17 @@ public class InternalClusterState {
 
 
     bytesInPerSec = statistics
     bytesInPerSec = statistics
         .getMetrics()
         .getMetrics()
-        .getBytesInPerSec()
+        .getBrokerBytesInPerSec()
         .values().stream()
         .values().stream()
-        .reduce(BigDecimal.ZERO, BigDecimal::add);
+        .reduce(BigDecimal::add)
+        .orElse(null);
 
 
     bytesOutPerSec = statistics
     bytesOutPerSec = statistics
         .getMetrics()
         .getMetrics()
-        .getBytesOutPerSec()
+        .getBrokerBytesOutPerSec()
         .values().stream()
         .values().stream()
-        .reduce(BigDecimal.ZERO, BigDecimal::add);
+        .reduce(BigDecimal::add)
+        .orElse(null);
 
 
     var partitionsStats = new PartitionsStats(statistics.getTopicDescriptions().values());
     var partitionsStats = new PartitionsStats(statistics.getTopicDescriptions().values());
     onlinePartitionCount = partitionsStats.getOnlinePartitionCount();
     onlinePartitionCount = partitionsStats.getOnlinePartitionCount();

+ 2 - 2
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalTopic.java

@@ -102,8 +102,8 @@ public class InternalTopic {
       topic.segmentSize(segmentStats.getSegmentSize());
       topic.segmentSize(segmentStats.getSegmentSize());
     }
     }
 
 
-    topic.bytesInPerSec(metrics.getBytesInPerSec().get(topicDescription.name()));
-    topic.bytesOutPerSec(metrics.getBytesOutPerSec().get(topicDescription.name()));
+    topic.bytesInPerSec(metrics.getTopicBytesInPerSec().get(topicDescription.name()));
+    topic.bytesOutPerSec(metrics.getTopicBytesOutPerSec().get(topicDescription.name()));
 
 
     topic.topicConfigs(
     topic.topicConfigs(
         configs.stream().map(InternalTopicConfig::from).collect(Collectors.toList()));
         configs.stream().map(InternalTopicConfig::from).collect(Collectors.toList()));

+ 9 - 4
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/Metrics.java

@@ -15,14 +15,19 @@ import lombok.Value;
 @Builder
 @Builder
 @Value
 @Value
 public class Metrics {
 public class Metrics {
-  Map<String, BigDecimal> bytesInPerSec;
-  Map<String, BigDecimal> bytesOutPerSec;
+
+  Map<Integer, BigDecimal> brokerBytesInPerSec;
+  Map<Integer, BigDecimal> brokerBytesOutPerSec;
+  Map<String, BigDecimal> topicBytesInPerSec;
+  Map<String, BigDecimal> topicBytesOutPerSec;
   Map<Integer, List<RawMetric>> perBrokerMetrics;
   Map<Integer, List<RawMetric>> perBrokerMetrics;
 
 
   public static Metrics empty() {
   public static Metrics empty() {
     return Metrics.builder()
     return Metrics.builder()
-        .bytesInPerSec(Map.of())
-        .bytesOutPerSec(Map.of())
+        .brokerBytesInPerSec(Map.of())
+        .brokerBytesOutPerSec(Map.of())
+        .topicBytesInPerSec(Map.of())
+        .topicBytesOutPerSec(Map.of())
         .perBrokerMetrics(Map.of())
         .perBrokerMetrics(Map.of())
         .build();
         .build();
   }
   }

+ 4 - 9
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java

@@ -5,9 +5,9 @@ import com.provectus.kafka.ui.exception.LogDirNotFoundApiException;
 import com.provectus.kafka.ui.exception.NotFoundException;
 import com.provectus.kafka.ui.exception.NotFoundException;
 import com.provectus.kafka.ui.exception.TopicOrPartitionNotFoundException;
 import com.provectus.kafka.ui.exception.TopicOrPartitionNotFoundException;
 import com.provectus.kafka.ui.mapper.DescribeLogDirsMapper;
 import com.provectus.kafka.ui.mapper.DescribeLogDirsMapper;
-import com.provectus.kafka.ui.model.BrokerDTO;
 import com.provectus.kafka.ui.model.BrokerLogdirUpdateDTO;
 import com.provectus.kafka.ui.model.BrokerLogdirUpdateDTO;
 import com.provectus.kafka.ui.model.BrokersLogdirsDTO;
 import com.provectus.kafka.ui.model.BrokersLogdirsDTO;
+import com.provectus.kafka.ui.model.InternalBroker;
 import com.provectus.kafka.ui.model.InternalBrokerConfig;
 import com.provectus.kafka.ui.model.InternalBrokerConfig;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.service.metrics.RawMetric;
 import com.provectus.kafka.ui.service.metrics.RawMetric;
@@ -63,18 +63,13 @@ public class BrokerService {
         .flatMapMany(Flux::fromIterable);
         .flatMapMany(Flux::fromIterable);
   }
   }
 
 
-  public Flux<BrokerDTO> getBrokers(KafkaCluster cluster) {
+  public Flux<InternalBroker> getBrokers(KafkaCluster cluster) {
     return adminClientService
     return adminClientService
         .get(cluster)
         .get(cluster)
         .flatMap(ReactiveAdminClient::describeCluster)
         .flatMap(ReactiveAdminClient::describeCluster)
         .map(description -> description.getNodes().stream()
         .map(description -> description.getNodes().stream()
-            .map(node -> {
-              BrokerDTO broker = new BrokerDTO();
-              broker.setId(node.id());
-              broker.setHost(node.host());
-              broker.setPort(node.port());
-              return broker;
-            }).collect(Collectors.toList()))
+            .map(node -> new InternalBroker(node, statisticsCache.get(cluster)))
+            .collect(Collectors.toList()))
         .flatMapMany(Flux::fromIterable);
         .flatMapMany(Flux::fromIterable);
   }
   }
 
 

+ 17 - 22
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/ReactiveAdminClient.java

@@ -1,6 +1,5 @@
 package com.provectus.kafka.ui.service;
 package com.provectus.kafka.ui.service;
 
 
-import static com.google.common.util.concurrent.Uninterruptibles.getUninterruptibly;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toMap;
 import static org.apache.kafka.clients.admin.ListOffsetsResult.ListOffsetsResultInfo;
 import static org.apache.kafka.clients.admin.ListOffsetsResult.ListOffsetsResultInfo;
@@ -43,6 +42,7 @@ import org.apache.kafka.clients.admin.Config;
 import org.apache.kafka.clients.admin.ConfigEntry;
 import org.apache.kafka.clients.admin.ConfigEntry;
 import org.apache.kafka.clients.admin.ConsumerGroupDescription;
 import org.apache.kafka.clients.admin.ConsumerGroupDescription;
 import org.apache.kafka.clients.admin.ConsumerGroupListing;
 import org.apache.kafka.clients.admin.ConsumerGroupListing;
+import org.apache.kafka.clients.admin.DescribeClusterOptions;
 import org.apache.kafka.clients.admin.DescribeConfigsOptions;
 import org.apache.kafka.clients.admin.DescribeConfigsOptions;
 import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsOptions;
 import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsOptions;
 import org.apache.kafka.clients.admin.ListOffsetsResult;
 import org.apache.kafka.clients.admin.ListOffsetsResult;
@@ -53,6 +53,7 @@ import org.apache.kafka.clients.admin.NewTopic;
 import org.apache.kafka.clients.admin.OffsetSpec;
 import org.apache.kafka.clients.admin.OffsetSpec;
 import org.apache.kafka.clients.admin.RecordsToDelete;
 import org.apache.kafka.clients.admin.RecordsToDelete;
 import org.apache.kafka.clients.admin.TopicDescription;
 import org.apache.kafka.clients.admin.TopicDescription;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.clients.consumer.OffsetAndMetadata;
 import org.apache.kafka.clients.consumer.OffsetAndMetadata;
 import org.apache.kafka.common.KafkaException;
 import org.apache.kafka.common.KafkaException;
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.KafkaFuture;
@@ -125,7 +126,8 @@ public class ReactiveAdminClient implements Closeable {
     }
     }
   }
   }
 
 
-  //TODO: discuss - maybe we should map kafka-library's exceptions to our exceptions here
+  // NOTE: if KafkaFuture returns null, that Mono will be empty(!), since Reactor does not support nullable results
+  // (see MonoSink.success(..) javadoc for details)
   private static <T> Mono<T> toMono(KafkaFuture<T> future) {
   private static <T> Mono<T> toMono(KafkaFuture<T> future) {
     return Mono.<T>create(sink -> future.whenComplete((res, ex) -> {
     return Mono.<T>create(sink -> future.whenComplete((res, ex) -> {
       if (ex != null) {
       if (ex != null) {
@@ -302,26 +304,19 @@ public class ReactiveAdminClient implements Closeable {
   }
   }
 
 
   private static Mono<ClusterDescription> describeClusterImpl(AdminClient client) {
   private static Mono<ClusterDescription> describeClusterImpl(AdminClient client) {
-    var r = client.describeCluster();
-    var all = KafkaFuture.allOf(r.nodes(), r.clusterId(), r.controller(), r.authorizedOperations());
-    return Mono.create(sink -> all.whenComplete((res, ex) -> {
-      if (ex != null) {
-        sink.error(ex);
-      } else {
-        try {
-          sink.success(
-              new ClusterDescription(
-                  getUninterruptibly(r.controller()),
-                  getUninterruptibly(r.clusterId()),
-                  getUninterruptibly(r.nodes()),
-                  getUninterruptibly(r.authorizedOperations())
-              )
-          );
-        } catch (ExecutionException e) {
-          // can't be here, because all futures already completed
-        }
-      }
-    }));
+    var result = client.describeCluster(new DescribeClusterOptions().includeAuthorizedOperations(true));
+    var allOfFuture = KafkaFuture.allOf(
+        result.controller(), result.clusterId(), result.nodes(), result.authorizedOperations());
+    return toMono(allOfFuture).then(
+        Mono.fromCallable(() ->
+          new ClusterDescription(
+            result.controller().get(),
+            result.clusterId().get(),
+            result.nodes().get(),
+            result.authorizedOperations().get()
+          )
+        )
+    );
   }
   }
 
 
   private static Mono<String> getClusterVersion(AdminClient client) {
   private static Mono<String> getClusterVersion(AdminClient client) {

+ 1 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/metrics/MetricsCollector.java

@@ -62,7 +62,7 @@ public class MetricsCollector {
     WellKnownMetrics wellKnownMetrics = new WellKnownMetrics();
     WellKnownMetrics wellKnownMetrics = new WellKnownMetrics();
     perBrokerMetrics.forEach((node, metrics) ->
     perBrokerMetrics.forEach((node, metrics) ->
         metrics.forEach(metric ->
         metrics.forEach(metric ->
-            wellKnownMetrics.populate(cluster, node, metric)));
+            wellKnownMetrics.populate(node, metric)));
     return wellKnownMetrics;
     return wellKnownMetrics;
   }
   }
 
 

+ 29 - 4
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/metrics/WellKnownMetrics.java

@@ -3,7 +3,6 @@ package com.provectus.kafka.ui.service.metrics;
 import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
 import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
 import static org.apache.commons.lang3.StringUtils.endsWithIgnoreCase;
 import static org.apache.commons.lang3.StringUtils.endsWithIgnoreCase;
 
 
-import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.Metrics;
 import com.provectus.kafka.ui.model.Metrics;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.HashMap;
 import java.util.HashMap;
@@ -12,16 +11,42 @@ import org.apache.kafka.common.Node;
 
 
 class WellKnownMetrics {
 class WellKnownMetrics {
 
 
+  // per broker
+  final Map<Integer, BigDecimal> brokerBytesInFifteenMinuteRate = new HashMap<>();
+  final Map<Integer, BigDecimal> brokerBytesOutFifteenMinuteRate = new HashMap<>();
+
+  // per topic
   final Map<String, BigDecimal> bytesInFifteenMinuteRate = new HashMap<>();
   final Map<String, BigDecimal> bytesInFifteenMinuteRate = new HashMap<>();
   final Map<String, BigDecimal> bytesOutFifteenMinuteRate = new HashMap<>();
   final Map<String, BigDecimal> bytesOutFifteenMinuteRate = new HashMap<>();
 
 
-  void populate(KafkaCluster cluster, Node node, RawMetric rawMetric) {
+  void populate(Node node, RawMetric rawMetric) {
+    updateBrokerIOrates(node, rawMetric);
     updateTopicsIOrates(rawMetric);
     updateTopicsIOrates(rawMetric);
   }
   }
 
 
   void apply(Metrics.MetricsBuilder metricsBuilder) {
   void apply(Metrics.MetricsBuilder metricsBuilder) {
-    metricsBuilder.bytesInPerSec(bytesInFifteenMinuteRate);
-    metricsBuilder.bytesOutPerSec(bytesOutFifteenMinuteRate);
+    metricsBuilder.topicBytesInPerSec(bytesInFifteenMinuteRate);
+    metricsBuilder.topicBytesOutPerSec(bytesOutFifteenMinuteRate);
+    metricsBuilder.brokerBytesInPerSec(brokerBytesInFifteenMinuteRate);
+    metricsBuilder.brokerBytesOutPerSec(brokerBytesOutFifteenMinuteRate);
+  }
+
+  private void updateBrokerIOrates(Node node, RawMetric rawMetric) {
+    String name = rawMetric.name();
+    if (!brokerBytesInFifteenMinuteRate.containsKey(node.id())
+        && rawMetric.labels().size() == 1
+        && "BytesInPerSec".equalsIgnoreCase(rawMetric.labels().get("name"))
+        && containsIgnoreCase(name, "BrokerTopicMetrics")
+        && endsWithIgnoreCase(name, "FifteenMinuteRate")) {
+      brokerBytesInFifteenMinuteRate.put(node.id(),  rawMetric.value());
+    }
+    if (!brokerBytesOutFifteenMinuteRate.containsKey(node.id())
+        && rawMetric.labels().size() == 1
+        && "BytesOutPerSec".equalsIgnoreCase(rawMetric.labels().get("name"))
+        && containsIgnoreCase(name, "BrokerTopicMetrics")
+        && endsWithIgnoreCase(name, "FifteenMinuteRate")) {
+      brokerBytesOutFifteenMinuteRate.put(node.id(), rawMetric.value());
+    }
   }
   }
 
 
   private void updateTopicsIOrates(RawMetric rawMetric) {
   private void updateTopicsIOrates(RawMetric rawMetric) {

+ 3 - 7
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/BrokerServiceTest.java

@@ -1,7 +1,6 @@
 package com.provectus.kafka.ui.service;
 package com.provectus.kafka.ui.service;
 
 
 import com.provectus.kafka.ui.AbstractIntegrationTest;
 import com.provectus.kafka.ui.AbstractIntegrationTest;
-import com.provectus.kafka.ui.model.BrokerDTO;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import reactor.test.StepVerifier;
 import reactor.test.StepVerifier;
@@ -16,14 +15,11 @@ class BrokerServiceTest extends AbstractIntegrationTest {
 
 
   @Test
   @Test
   void getBrokersReturnsFilledBrokerDto() {
   void getBrokersReturnsFilledBrokerDto() {
-    BrokerDTO expectedBroker = new BrokerDTO();
-    expectedBroker.setId(1);
-    expectedBroker.setHost(kafka.getHost());
-    expectedBroker.setPort(kafka.getFirstMappedPort());
-
     var localCluster = clustersStorage.getClusterByName(LOCAL).get();
     var localCluster = clustersStorage.getClusterByName(LOCAL).get();
     StepVerifier.create(brokerService.getBrokers(localCluster))
     StepVerifier.create(brokerService.getBrokers(localCluster))
-        .expectNext(expectedBroker)
+        .expectNextMatches(b -> b.getId().equals(1)
+            && b.getHost().equals(kafka.getHost())
+            && b.getPort().equals(kafka.getFirstMappedPort()))
         .verifyComplete();
         .verifyComplete();
   }
   }
 
 

+ 61 - 34
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/metrics/WellKnownMetricsTest.java

@@ -2,65 +2,92 @@ package com.provectus.kafka.ui.service.metrics;
 
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThat;
 
 
-import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.Metrics;
 import com.provectus.kafka.ui.model.Metrics;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
+import java.util.Arrays;
 import java.util.Map;
 import java.util.Map;
+import java.util.Optional;
 import org.apache.kafka.common.Node;
 import org.apache.kafka.common.Node;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.CsvSource;
 
 
 class WellKnownMetricsTest {
 class WellKnownMetricsTest {
 
 
   private final WellKnownMetrics wellKnownMetrics = new WellKnownMetrics();
   private final WellKnownMetrics wellKnownMetrics = new WellKnownMetrics();
 
 
-  @ParameterizedTest
-  @CsvSource({
-      //default jmx exporter format
-      //example: kafka_server_BrokerTopicMetrics_FifteenMinuteRate{name="BytesInPerSec",topic="test-topic",} 222.0
-      //example: kafka_server_BrokerTopicMetrics_FifteenMinuteRate{name="BytesOutPerSec",topic="test-topic",} 111.0
-      "kafka_server_BrokerTopicMetrics_FifteenMinuteRate, BytesInPerSec, BytesOutPerSec",
-
-      //default jmx exporter format, but lower-cased
-      //example: kafka_server_brokertopicmetrics_fifteenminuterate{name="bytesinpersec",topic="test-topic",} 222.0
-      //example: kafka_server_brokertopicmetrics_fifteenminuterate{name="bytesoutpersec",topic="test-topic",} 111.0
-      "kafka_server_brokertopicmetrics_fifteenminuterate, bytesinpersec, bytesoutpersec",
-
-      //unknown prefix metric name
-      "some_unknown_prefix_brokertopicmetrics_fifteenminuterate, bytesinpersec, bytesoutpersec",
-  })
-  void bytesIoTopicMetricsPopulated(String metricName, String bytesInLabel, String bytesOutLabel) {
-    var clusterParam = KafkaCluster.builder().build();
-    var nodeParam = new Node(0, "host", 123);
-
-    var in = RawMetric.create(metricName, Map.of("name", bytesInLabel, "topic", "test-topic"), new BigDecimal("1.0"));
-    var out = RawMetric.create(metricName, Map.of("name", bytesOutLabel, "topic", "test-topic"), new BigDecimal("2.0"));
-
-    // feeding metrics
-    for (int i = 0; i < 3; i++) {
-      wellKnownMetrics.populate(clusterParam, nodeParam, in);
-      wellKnownMetrics.populate(clusterParam, nodeParam, out);
-    }
-
+  @Test
+  void bytesIoTopicMetricsPopulated() {
+    populateWith(
+        new Node(0, "host", 123),
+        "kafka_server_BrokerTopicMetrics_FifteenMinuteRate{name=\"BytesInPerSec\",topic=\"test-topic\",} 1.0",
+        "kafka_server_BrokerTopicMetrics_FifteenMinuteRate{name=\"BytesOutPerSec\",topic=\"test-topic\",} 2.0",
+        "kafka_server_brokertopicmetrics_fifteenminuterate{name=\"bytesinpersec\",topic=\"test-topic\",} 1.0",
+        "kafka_server_brokertopicmetrics_fifteenminuterate{name=\"bytesoutpersec\",topic=\"test-topic\",} 2.0",
+        "some_unknown_prefix_brokertopicmetrics_fifteenminuterate{name=\"bytesinpersec\",topic=\"test-topic\",} 1.0",
+        "some_unknown_prefix_brokertopicmetrics_fifteenminuterate{name=\"bytesoutpersec\",topic=\"test-topic\",} 2.0"
+    );
     assertThat(wellKnownMetrics.bytesInFifteenMinuteRate)
     assertThat(wellKnownMetrics.bytesInFifteenMinuteRate)
         .containsEntry("test-topic", new BigDecimal("3.0"));
         .containsEntry("test-topic", new BigDecimal("3.0"));
-
     assertThat(wellKnownMetrics.bytesOutFifteenMinuteRate)
     assertThat(wellKnownMetrics.bytesOutFifteenMinuteRate)
         .containsEntry("test-topic", new BigDecimal("6.0"));
         .containsEntry("test-topic", new BigDecimal("6.0"));
   }
   }
 
 
+  @Test
+  void bytesIoBrokerMetricsPopulated() {
+    populateWith(
+        new Node(1, "host1", 123),
+        "kafka_server_BrokerTopicMetrics_FifteenMinuteRate{name=\"BytesInPerSec\",} 1.0",
+        "kafka_server_BrokerTopicMetrics_FifteenMinuteRate{name=\"BytesOutPerSec\",} 2.0"
+    );
+    populateWith(
+        new Node(2, "host2", 345),
+        "some_unknown_prefix_brokertopicmetrics_fifteenminuterate{name=\"bytesinpersec\",} 10.0",
+        "some_unknown_prefix_brokertopicmetrics_fifteenminuterate{name=\"bytesoutpersec\",} 20.0"
+    );
+
+    assertThat(wellKnownMetrics.brokerBytesInFifteenMinuteRate)
+        .hasSize(2)
+        .containsEntry(1, new BigDecimal("1.0"))
+        .containsEntry(2, new BigDecimal("10.0"));
+
+    assertThat(wellKnownMetrics.brokerBytesOutFifteenMinuteRate)
+        .hasSize(2)
+        .containsEntry(1, new BigDecimal("2.0"))
+        .containsEntry(2, new BigDecimal("20.0"));
+  }
+
   @Test
   @Test
   void appliesInnerStateToMetricsBuilder() {
   void appliesInnerStateToMetricsBuilder() {
+    //filling per topic io rates
     wellKnownMetrics.bytesInFifteenMinuteRate.put("topic", new BigDecimal(1));
     wellKnownMetrics.bytesInFifteenMinuteRate.put("topic", new BigDecimal(1));
     wellKnownMetrics.bytesOutFifteenMinuteRate.put("topic", new BigDecimal(2));
     wellKnownMetrics.bytesOutFifteenMinuteRate.put("topic", new BigDecimal(2));
 
 
+    //filling per broker io rates
+    wellKnownMetrics.brokerBytesInFifteenMinuteRate.put(1, new BigDecimal(1));
+    wellKnownMetrics.brokerBytesOutFifteenMinuteRate.put(1, new BigDecimal(2));
+    wellKnownMetrics.brokerBytesInFifteenMinuteRate.put(2, new BigDecimal(10));
+    wellKnownMetrics.brokerBytesOutFifteenMinuteRate.put(2, new BigDecimal(20));
+
     Metrics.MetricsBuilder builder = Metrics.builder();
     Metrics.MetricsBuilder builder = Metrics.builder();
     wellKnownMetrics.apply(builder);
     wellKnownMetrics.apply(builder);
     var metrics = builder.build();
     var metrics = builder.build();
 
 
-    assertThat(metrics.getBytesInPerSec()).containsExactlyEntriesOf(wellKnownMetrics.bytesInFifteenMinuteRate);
-    assertThat(metrics.getBytesOutPerSec()).containsExactlyEntriesOf(wellKnownMetrics.bytesOutFifteenMinuteRate);
+    // checking per topic io rates
+    assertThat(metrics.getTopicBytesInPerSec()).containsExactlyEntriesOf(wellKnownMetrics.bytesInFifteenMinuteRate);
+    assertThat(metrics.getTopicBytesOutPerSec()).containsExactlyEntriesOf(wellKnownMetrics.bytesOutFifteenMinuteRate);
+
+    // checking per broker io rates
+    assertThat(metrics.getBrokerBytesInPerSec()).containsExactlyInAnyOrderEntriesOf(
+        Map.of(1, new BigDecimal(1), 2, new BigDecimal(10)));
+    assertThat(metrics.getBrokerBytesOutPerSec()).containsExactlyInAnyOrderEntriesOf(
+        Map.of(1, new BigDecimal(2), 2, new BigDecimal(20)));
+  }
+
+  private void populateWith(Node n, String... prometheusMetric) {
+    Arrays.stream(prometheusMetric)
+        .map(PrometheusEndpointMetricsParser::parse)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .forEach(m -> wellKnownMetrics.populate(n, m));
   }
   }
 
 
 }
 }

+ 5 - 0
kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml

@@ -1901,6 +1901,7 @@ components:
           deprecated: true
           deprecated: true
         activeControllers:
         activeControllers:
           type: integer
           type: integer
+          description: Id of broker which is cluster's controller. null, if controller not known yet.
         onlinePartitionCount:
         onlinePartitionCount:
           type: integer
           type: integer
         offlinePartitionCount:
         offlinePartitionCount:
@@ -2269,6 +2270,10 @@ components:
           type: string
           type: string
         port:
         port:
           type: integer
           type: integer
+        bytesInPerSec:
+          type: number
+        bytesOutPerSec:
+          type: number
       required:
       required:
         - id
         - id
 
 

+ 16 - 0
kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.styled.ts

@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+export const RowCell = styled.div`
+  display: flex;
+  width: 100%;
+  align-items: center;
+
+  svg {
+    width: 20px;
+    padding-left: 6px;
+  }
+`;
+
+export const DangerText = styled.span`
+  color: ${({ theme }) => theme.circularAlert.color.error};
+`;

+ 38 - 10
kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx

@@ -7,8 +7,12 @@ import useAppParams from 'lib/hooks/useAppParams';
 import { useBrokers } from 'lib/hooks/api/brokers';
 import { useBrokers } from 'lib/hooks/api/brokers';
 import { useClusterStats } from 'lib/hooks/api/clusters';
 import { useClusterStats } from 'lib/hooks/api/clusters';
 import Table, { LinkCell, SizeCell } from 'components/common/NewTable';
 import Table, { LinkCell, SizeCell } from 'components/common/NewTable';
+import CheckMarkRoundIcon from 'components/common/Icons/CheckMarkRoundIcon';
 import { ColumnDef } from '@tanstack/react-table';
 import { ColumnDef } from '@tanstack/react-table';
 import { clusterBrokerPath } from 'lib/paths';
 import { clusterBrokerPath } from 'lib/paths';
+import Tooltip from 'components/common/Tooltip/Tooltip';
+
+import * as S from './BrokersList.styled';
 
 
 const NA = 'N/A';
 const NA = 'N/A';
 
 
@@ -56,17 +60,27 @@ const BrokersList: React.FC = () => {
       };
       };
     });
     });
   }, [diskUsage, brokers]);
   }, [diskUsage, brokers]);
+
   const columns = React.useMemo<ColumnDef<typeof rows>[]>(
   const columns = React.useMemo<ColumnDef<typeof rows>[]>(
     () => [
     () => [
       {
       {
         header: 'Broker ID',
         header: 'Broker ID',
         accessorKey: 'brokerId',
         accessorKey: 'brokerId',
         // eslint-disable-next-line react/no-unstable-nested-components
         // eslint-disable-next-line react/no-unstable-nested-components
-        cell: ({ getValue }) => (
-          <LinkCell
-            value={`${getValue<string | number>()}`}
-            to={encodeURIComponent(`${getValue<string | number>()}`)}
-          />
+        cell: ({ row: { id }, getValue }) => (
+          <S.RowCell>
+            <LinkCell
+              value={`${getValue<string | number>()}`}
+              to={encodeURIComponent(`${getValue<string | number>()}`)}
+            />
+            {id === String(activeControllers) && (
+              <Tooltip
+                value={<CheckMarkRoundIcon />}
+                content="Active Controller"
+                placement="right"
+              />
+            )}
+          </S.RowCell>
         ),
         ),
       },
       },
       {
       {
@@ -89,7 +103,10 @@ const BrokersList: React.FC = () => {
       },
       },
       { header: 'Segment Count', accessorKey: 'count' },
       { header: 'Segment Count', accessorKey: 'count' },
       { header: 'Port', accessorKey: 'port' },
       { header: 'Port', accessorKey: 'port' },
-      { header: 'Host', accessorKey: 'host' },
+      {
+        header: 'Host',
+        accessorKey: 'host',
+      },
     ],
     ],
     []
     []
   );
   );
@@ -98,6 +115,8 @@ const BrokersList: React.FC = () => {
   const areAllInSync = inSyncReplicasCount && replicas === inSyncReplicasCount;
   const areAllInSync = inSyncReplicasCount && replicas === inSyncReplicasCount;
   const partitionIsOffline = offlinePartitionCount && offlinePartitionCount > 0;
   const partitionIsOffline = offlinePartitionCount && offlinePartitionCount > 0;
 
 
+  const isActiveControllerUnKnown = typeof activeControllers === 'undefined';
+
   return (
   return (
     <>
     <>
       <PageHeading text="Brokers" />
       <PageHeading text="Brokers" />
@@ -106,8 +125,15 @@ const BrokersList: React.FC = () => {
           <Metrics.Indicator label="Broker Count">
           <Metrics.Indicator label="Broker Count">
             {brokerCount}
             {brokerCount}
           </Metrics.Indicator>
           </Metrics.Indicator>
-          <Metrics.Indicator label="Active Controllers">
-            {activeControllers}
+          <Metrics.Indicator
+            label="Active Controller"
+            isAlert={isActiveControllerUnKnown}
+          >
+            {isActiveControllerUnKnown ? (
+              <S.DangerText>No Active Controller</S.DangerText>
+            ) : (
+              activeControllers
+            )}
           </Metrics.Indicator>
           </Metrics.Indicator>
           <Metrics.Indicator label="Version">{version}</Metrics.Indicator>
           <Metrics.Indicator label="Version">{version}</Metrics.Indicator>
         </Metrics.Section>
         </Metrics.Section>
@@ -123,8 +149,10 @@ const BrokersList: React.FC = () => {
               onlinePartitionCount
               onlinePartitionCount
             )}
             )}
             <Metrics.LightText>
             <Metrics.LightText>
-              {' '}
-              of {(onlinePartitionCount || 0) + (offlinePartitionCount || 0)}
+              {` of ${
+                (onlinePartitionCount || 0) + (offlinePartitionCount || 0)
+              }
+              `}
             </Metrics.LightText>
             </Metrics.LightText>
           </Metrics.Indicator>
           </Metrics.Indicator>
           <Metrics.Indicator
           <Metrics.Indicator

+ 22 - 0
kafka-ui-react-app/src/components/common/Icons/CheckMarkRoundIcon.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+const CheckMarkRoundIcon: React.FC = () => {
+  return (
+    <svg
+      width="14"
+      height="14"
+      viewBox="0 0 14 14"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M7 14C10.866 14 14 10.866 14 7C14 3.13401 10.866 0 7 0C3.13401 0 0 3.13401 0 7C0 10.866 3.13401 14 7 14ZM11.2796 5.39575C11.5831 5.1139 11.6007 4.63935 11.3188 4.33582C11.037 4.03228 10.5624 4.01471 10.2589 4.29656L6.13018 8.13037L3.74111 5.91194C3.43757 5.63009 2.96303 5.64767 2.68117 5.9512C2.39932 6.25473 2.4169 6.72928 2.72043 7.01113L5.61984 9.70344C5.9076 9.97065 6.35276 9.97065 6.64052 9.70344L11.2796 5.39575Z"
+        fill="#33CC66"
+      />
+    </svg>
+  );
+};
+
+export default CheckMarkRoundIcon;

+ 9 - 0
kafka-ui-react-app/src/components/common/Icons/StarIcon.tsx

@@ -0,0 +1,9 @@
+import React from 'react';
+
+const StarIcon: React.FC = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 245">
+    <path d="m56,237 74-228 74,228L10,96h240" />
+  </svg>
+);
+
+export default StarIcon;