Преглед изворни кода

Merge branch 'master' into checks/626-update-delete-topics

Anna Antipova пре 3 година
родитељ
комит
af16f93cdc
28 измењених фајлова са 1543 додато и 99 уклоњено
  1. 7 0
      README.md
  2. 19 0
      docker/connectors/sink-activities.json
  3. 20 0
      docker/connectors/source-activities.json
  4. 9 0
      docker/connectors/start.sh
  5. 5 0
      docker/kafka-connect/Dockerfile
  6. 176 0
      docker/kafka-ui-connectors.yaml
  7. 9 0
      docker/postgres/Dockerfile
  8. 24 0
      docker/postgres/data.sql
  9. 7 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java
  10. 20 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/mapper/ClusterMapper.java
  11. 12 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalSchemaRegistry.java
  12. 1 1
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/KafkaCluster.java
  13. 26 4
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/serde/schemaregistry/SchemaRegistryAwareRecordSerDe.java
  14. 76 25
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java
  15. 71 40
      kafka-ui-react-app/package-lock.json
  16. 2 1
      kafka-ui-react-app/package.json
  17. 4 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx
  18. 200 0
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone.tsx
  19. 50 0
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZoneContainer.ts
  20. 26 10
      kafka-ui-react-app/src/components/Topics/Topic/Edit/Edit.tsx
  21. 68 0
      kafka-ui-react-app/src/components/Topics/Topic/Edit/__tests__/DangerZone.spec.tsx
  22. 531 0
      kafka-ui-react-app/src/components/Topics/Topic/Edit/__tests__/__snapshots__/DangerZone.spec.tsx.snap
  23. 20 18
      kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
  24. 80 0
      kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts
  25. 12 0
      kafka-ui-react-app/src/redux/actions/actions.ts
  26. 52 0
      kafka-ui-react-app/src/redux/actions/thunks/topics.ts
  27. 15 0
      kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
  28. 1 0
      kafka-ui-react-app/src/setupTests.ts

+ 7 - 0
README.md

@@ -132,6 +132,9 @@ kafka:
       bootstrapServers: localhost:29091
       zookeeper: localhost:2183
       schemaRegistry: http://localhost:8085
+      schemaRegistryAuth:
+        username: username
+        password: password
 #     schemaNameTemplate: "%s-value"
       jmxPort: 9997
     -
@@ -141,6 +144,8 @@ kafka:
 * `bootstrapServers`: where to connect
 * `zookeeper`: zookeeper service address
 * `schemaRegistry`: schemaRegistry's address
+* `schemaRegistryAuth.username`: schemaRegistry's basic authentication username
+* `schemaRegistryAuth.password`: schemaRegistry's basic authentication password
 * `schemaNameTemplate`: how keys are saved to schemaRegistry
 * `jmxPort`: open jmxPosrts of a broker
 * `readOnly`: enable read only mode
@@ -160,6 +165,8 @@ For example, if you want to use an environment variable to set the `name` parame
 |`KAFKA_CLUSTERS_0_ZOOKEEPER` 	| Zookeper service address 
 |`KAFKA_CLUSTERS_0_PROPERTIES_SECURITY_PROTOCOL` 	|Security protocol to connect to the brokers. For SSL connection use "SSL", for plaintext connection don't set this environment variable
 |`KAFKA_CLUSTERS_0_SCHEMAREGISTRY`   	|SchemaRegistry's address
+|`KAFKA_CLUSTERS_0_SCHEMAREGISTRYAUTH_USERNAME`   	|SchemaRegistry's basic authentication username
+|`KAFKA_CLUSTERS_0_SCHEMAREGISTRYAUTH_PASSWORD`   	|SchemaRegistry's basic authentication password
 |`KAFKA_CLUSTERS_0_SCHEMANAMETEMPLATE`  |How keys are saved to schemaRegistry
 |`KAFKA_CLUSTERS_0_JMXPORT`        	|Open jmxPosrts of a broker
 |`KAFKA_CLUSTERS_0_READONLY`        	|Enable read only mode. Default: false

+ 19 - 0
docker/connectors/sink-activities.json

@@ -0,0 +1,19 @@
+{
+  "name": "sink_postgres_activities",
+  "config": {
+    "connector.class": "io.confluent.connect.jdbc.JdbcSinkConnector",
+    "connection.url": "jdbc:postgresql://postgres-db:5432/test",
+    "connection.user": "dev_user",
+    "connection.password": "12345",
+    "topics": "source-activities",
+    "table.name.format": "sink_activities",
+    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
+    "key.converter.schema.registry.url": "http://schemaregistry0:8085",
+    "value.converter": "io.confluent.connect.avro.AvroConverter",
+    "value.converter.schema.registry.url": "http://schemaregistry0:8085",
+    "auto.create": "true",
+    "pk.mode": "record_value",
+    "pk.fields": "id",
+    "insert.mode": "upsert"
+  }
+}

+ 20 - 0
docker/connectors/source-activities.json

@@ -0,0 +1,20 @@
+{
+  "name": "source_postgres_activities",
+  "config": {
+    "connector.class": "io.confluent.connect.jdbc.JdbcSourceConnector",
+    "connection.url": "jdbc:postgresql://postgres-db:5432/test",
+    "connection.user": "dev_user",
+    "connection.password": "12345",
+    "topic.prefix": "source-",
+    "poll.interval.ms": 3600000,
+    "table.whitelist": "public.activities",
+    "mode": "bulk",
+    "transforms": "extractkey",
+    "transforms.extractkey.type": "org.apache.kafka.connect.transforms.ExtractField$Key",
+    "transforms.extractkey.field": "id",
+    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
+    "key.converter.schema.registry.url": "http://schemaregistry0:8085",
+    "value.converter": "io.confluent.connect.avro.AvroConverter",
+    "value.converter.schema.registry.url": "http://schemaregistry0:8085"
+  }
+}

+ 9 - 0
docker/connectors/start.sh

@@ -0,0 +1,9 @@
+#! /bin/bash
+while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' kafka-connect0:8083)" != "200" ]]
+    do sleep 5
+done
+
+echo "\n --------------Creating connectors..."
+for filename in /connectors/*.json; do
+  curl -X POST -H "Content-Type: application/json" -d @$filename http://kafka-connect0:8083/connectors
+done

+ 5 - 0
docker/kafka-connect/Dockerfile

@@ -0,0 +1,5 @@
+ARG image
+FROM ${image}
+
+## Install connectors
+RUN echo "\nInstalling JDBC connector...\n" && confluent-hub install --no-prompt confluentinc/kafka-connect-jdbc:latest

+ 176 - 0
docker/kafka-ui-connectors.yaml

@@ -0,0 +1,176 @@
+---
+version: '2'
+services:
+
+  kafka-ui:
+    container_name: kafka-ui
+    image: provectuslabs/kafka-ui:master
+    ports:
+      - 8080:8080
+    depends_on:
+      - zookeeper0
+      - zookeeper1
+      - kafka0
+      - kafka1
+      - schemaregistry0
+      - kafka-connect0
+    environment:
+      KAFKA_CLUSTERS_0_NAME: local
+      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka0:29092
+      KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper0:2181
+      KAFKA_CLUSTERS_0_JMXPORT: 9997
+      KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schemaregistry0:8085
+      KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: first
+      KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://kafka-connect0:8083
+      KAFKA_CLUSTERS_1_NAME: secondLocal
+      KAFKA_CLUSTERS_1_BOOTSTRAPSERVERS: kafka1:29092
+      KAFKA_CLUSTERS_1_ZOOKEEPER: zookeeper1:2181
+      KAFKA_CLUSTERS_1_JMXPORT: 9998
+      KAFKA_CLUSTERS_1_SCHEMAREGISTRY: http://schemaregistry1:8085
+      KAFKA_CLUSTERS_1_KAFKACONNECT_0_NAME: first
+      KAFKA_CLUSTERS_1_KAFKACONNECT_0_ADDRESS: http://kafka-connect0:8083
+
+  zookeeper0:
+    image: confluentinc/cp-zookeeper:5.2.4
+    environment:
+      ZOOKEEPER_CLIENT_PORT: 2181
+      ZOOKEEPER_TICK_TIME: 2000
+    ports:
+      - 2181:2181
+
+  kafka0:
+    image: confluentinc/cp-kafka:5.2.4
+    depends_on:
+      - zookeeper0
+    ports:
+      - 9092:9092
+      - 9997:9997
+    environment:
+      KAFKA_BROKER_ID: 1
+      KAFKA_ZOOKEEPER_CONNECT: zookeeper0:2181
+      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka0:29092,PLAINTEXT_HOST://localhost:9092
+      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
+      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+      JMX_PORT: 9997
+      KAFKA_JMX_OPTS: -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=kafka0 -Dcom.sun.management.jmxremote.rmi.port=9997
+
+  zookeeper1:
+    image: confluentinc/cp-zookeeper:5.2.4
+    environment:
+      ZOOKEEPER_CLIENT_PORT: 2181
+      ZOOKEEPER_TICK_TIME: 2000
+
+  kafka1:
+    image: confluentinc/cp-kafka:5.2.4
+    depends_on:
+      - zookeeper1
+    ports:
+      - 9093:9093
+      - 9998:9998
+    environment:
+      KAFKA_BROKER_ID: 1
+      KAFKA_ZOOKEEPER_CONNECT: zookeeper1:2181
+      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka1:29092,PLAINTEXT_HOST://localhost:9093
+      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
+      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+      JMX_PORT: 9998
+      KAFKA_JMX_OPTS: -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=kafka1 -Dcom.sun.management.jmxremote.rmi.port=9998
+
+  schemaregistry0:
+    image: confluentinc/cp-schema-registry:5.2.4
+    ports:
+      - 8085:8085
+    depends_on:
+      - zookeeper0
+      - kafka0
+    environment:
+      SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: PLAINTEXT://kafka0:29092
+      SCHEMA_REGISTRY_KAFKASTORE_CONNECTION_URL: zookeeper0:2181
+      SCHEMA_REGISTRY_KAFKASTORE_SECURITY_PROTOCOL: PLAINTEXT
+      SCHEMA_REGISTRY_HOST_NAME: schemaregistry0
+      SCHEMA_REGISTRY_LISTENERS: http://schemaregistry0:8085
+
+      SCHEMA_REGISTRY_SCHEMA_REGISTRY_INTER_INSTANCE_PROTOCOL: "http"
+      SCHEMA_REGISTRY_LOG4J_ROOT_LOGLEVEL: INFO
+      SCHEMA_REGISTRY_KAFKASTORE_TOPIC: _schemas
+
+  schemaregistry1:
+    image: confluentinc/cp-schema-registry:5.5.0
+    ports:
+      - 18085:8085
+    depends_on:
+      - zookeeper1
+      - kafka1
+    environment:
+      SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: PLAINTEXT://kafka1:29092
+      SCHEMA_REGISTRY_KAFKASTORE_CONNECTION_URL: zookeeper1:2181
+      SCHEMA_REGISTRY_KAFKASTORE_SECURITY_PROTOCOL: PLAINTEXT
+      SCHEMA_REGISTRY_HOST_NAME: schemaregistry1
+      SCHEMA_REGISTRY_LISTENERS: http://schemaregistry1:8085
+
+      SCHEMA_REGISTRY_SCHEMA_REGISTRY_INTER_INSTANCE_PROTOCOL: "http"
+      SCHEMA_REGISTRY_LOG4J_ROOT_LOGLEVEL: INFO
+      SCHEMA_REGISTRY_KAFKASTORE_TOPIC: _schemas
+
+  kafka-connect0:
+    build:
+      context: ./kafka-connect
+      args:
+        image: confluentinc/cp-kafka-connect:6.0.1
+    ports:
+      - 8083:8083
+    depends_on:
+      - kafka0
+      - schemaregistry0
+    environment:
+      CONNECT_BOOTSTRAP_SERVERS: kafka0:29092
+      CONNECT_GROUP_ID: compose-connect-group
+      CONNECT_CONFIG_STORAGE_TOPIC: _connect_configs
+      CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: 1
+      CONNECT_OFFSET_STORAGE_TOPIC: _connect_offset
+      CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: 1
+      CONNECT_STATUS_STORAGE_TOPIC: _connect_status
+      CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: 1
+      CONNECT_KEY_CONVERTER: org.apache.kafka.connect.storage.StringConverter
+      CONNECT_KEY_CONVERTER_SCHEMA_REGISTRY_URL: http://schemaregistry0:8085
+      CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.storage.StringConverter
+      CONNECT_VALUE_CONVERTER_SCHEMA_REGISTRY_URL: http://schemaregistry0:8085
+      CONNECT_INTERNAL_KEY_CONVERTER: org.apache.kafka.connect.json.JsonConverter
+      CONNECT_INTERNAL_VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter
+      CONNECT_REST_ADVERTISED_HOST_NAME: kafka-connect0
+      CONNECT_PLUGIN_PATH: "/usr/share/java,/usr/share/confluent-hub-components"
+
+  kafka-init-topics:
+    image: confluentinc/cp-kafka:5.2.4
+    volumes:
+      - ./message.json:/data/message.json
+    depends_on:
+      - kafka1
+    command: "bash -c 'echo Waiting for Kafka to be ready... && \
+               cub kafka-ready -b kafka1:29092 1 30 && \
+               kafka-topics --create --topic second.users --partitions 3 --replication-factor 1 --if-not-exists --zookeeper zookeeper1:2181 && \
+               kafka-topics --create --topic second.messages --partitions 2 --replication-factor 1 --if-not-exists --zookeeper zookeeper1:2181 && \
+               kafka-topics --create --topic first.messages --partitions 2 --replication-factor 1 --if-not-exists --zookeeper zookeeper0:2181 && \
+               kafka-console-producer --broker-list kafka1:29092 -topic second.users < /data/message.json'"
+
+  postgres-db:
+    build:
+      context: ./postgres
+      args:
+        image: postgres:9.6.22
+    ports:
+      - 5432:5432
+    environment:
+      POSTGRES_USER: 'dev_user'
+      POSTGRES_PASSWORD: '12345'
+
+  create-connectors:
+    image: ellerbrock/alpine-bash-curl-ssl
+    depends_on:
+      - postgres-db
+      - kafka-connect0
+    volumes:
+      - ./connectors:/connectors
+    command: bash -c '/connectors/start.sh'

+ 9 - 0
docker/postgres/Dockerfile

@@ -0,0 +1,9 @@
+ARG image
+
+FROM ${image}
+
+MAINTAINER Provectus Team
+
+ADD data.sql /docker-entrypoint-initdb.d
+
+EXPOSE 5432

+ 24 - 0
docker/postgres/data.sql

@@ -0,0 +1,24 @@
+CREATE DATABASE test WITH OWNER = dev_user;
+\connect test
+
+CREATE TABLE activities
+(
+    id        INTEGER PRIMARY KEY,
+    msg       varchar(24),
+    action    varchar(128),
+    browser   varchar(24),
+    device    json,
+    createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+insert into activities(id, action, msg, browser, device)
+values (1, 'LOGIN', 'Success', 'Chrome', '{
+  "name": "Chrome",
+  "major": "67",
+  "version": "67.0.3396.99"
+}'),
+       (2, 'LOGIN', 'Failed', 'Apple WebKit', '{
+         "name": "WebKit",
+         "major": "605",
+         "version": "605.1.15"
+       }');

+ 7 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java

@@ -20,6 +20,7 @@ public class ClustersProperties {
     String bootstrapServers;
     String zookeeper;
     String schemaRegistry;
+    SchemaRegistryAuth schemaRegistryAuth;
     String schemaNameTemplate = "%s-value";
     String keySchemaNameTemplate = "%s-key";
     String protobufFile;
@@ -35,4 +36,10 @@ public class ClustersProperties {
     String name;
     String address;
   }
+
+  @Data
+  public static class SchemaRegistryAuth {
+    String username;
+    String password;
+  }
 }

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

@@ -15,6 +15,7 @@ import com.provectus.kafka.ui.model.InternalBrokerMetrics;
 import com.provectus.kafka.ui.model.InternalClusterMetrics;
 import com.provectus.kafka.ui.model.InternalPartition;
 import com.provectus.kafka.ui.model.InternalReplica;
+import com.provectus.kafka.ui.model.InternalSchemaRegistry;
 import com.provectus.kafka.ui.model.InternalTopic;
 import com.provectus.kafka.ui.model.InternalTopicConfig;
 import com.provectus.kafka.ui.model.KafkaCluster;
@@ -49,6 +50,7 @@ public interface ClusterMapper {
 
   @Mapping(target = "protobufFile", source = "protobufFile", qualifiedByName = "resolvePath")
   @Mapping(target = "properties", source = "properties", qualifiedByName = "setProperties")
+  @Mapping(target = "schemaRegistry", source = ".", qualifiedByName = "setSchemaRegistry")
   KafkaCluster toKafkaCluster(ClustersProperties.Cluster clusterProperties);
 
   @Mapping(target = "diskUsage", source = "internalBrokerDiskUsage",
@@ -64,6 +66,24 @@ public interface ClusterMapper {
 
   Partition toPartition(InternalPartition topic);
 
+  default InternalSchemaRegistry setSchemaRegistry(ClustersProperties.Cluster clusterProperties) {
+    if (clusterProperties == null) {
+      return null;
+    }
+
+    InternalSchemaRegistry.InternalSchemaRegistryBuilder internalSchemaRegistry =
+        InternalSchemaRegistry.builder();
+
+    internalSchemaRegistry.url(clusterProperties.getSchemaRegistry());
+
+    if (clusterProperties.getSchemaRegistryAuth() != null) {
+      internalSchemaRegistry.username(clusterProperties.getSchemaRegistryAuth().getUsername());
+      internalSchemaRegistry.password(clusterProperties.getSchemaRegistryAuth().getPassword());
+    }
+
+    return internalSchemaRegistry.build();
+  }
+
   TopicDetails toTopicDetails(InternalTopic topic);
 
   default TopicDetails toTopicDetails(InternalTopic topic, InternalClusterMetrics metrics) {

+ 12 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalSchemaRegistry.java

@@ -0,0 +1,12 @@
+package com.provectus.kafka.ui.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder(toBuilder = true)
+public class InternalSchemaRegistry {
+  private final String username;
+  private final String password;
+  private final String url;
+}

+ 1 - 1
kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/KafkaCluster.java

@@ -15,7 +15,7 @@ public class KafkaCluster {
   private final Integer jmxPort;
   private final String bootstrapServers;
   private final String zookeeper;
-  private final String schemaRegistry;
+  private final InternalSchemaRegistry schemaRegistry;
   private final List<KafkaConnectCluster> kafkaConnect;
   private final String schemaNameTemplate;
   private final String keySchemaNameTemplate;

+ 26 - 4
kafka-ui-api/src/main/java/com/provectus/kafka/ui/serde/schemaregistry/SchemaRegistryAwareRecordSerDe.java

@@ -1,6 +1,11 @@
 package com.provectus.kafka.ui.serde.schemaregistry;
 
+
+import static io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.BASIC_AUTH_CREDENTIALS_SOURCE;
+import static io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.USER_INFO_CONFIG;
+
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.provectus.kafka.ui.exception.ValidationException;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.MessageSchema;
 import com.provectus.kafka.ui.model.TopicMessageSchema;
@@ -22,6 +27,7 @@ import io.confluent.kafka.schemaregistry.protobuf.ProtobufSchemaProvider;
 import java.net.URI;
 import java.nio.ByteBuffer;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -63,14 +69,29 @@ public class SchemaRegistryAwareRecordSerDe implements RecordSerDe {
 
   private static SchemaRegistryClient createSchemaRegistryClient(KafkaCluster cluster) {
     Objects.requireNonNull(cluster.getSchemaRegistry());
+    Objects.requireNonNull(cluster.getSchemaRegistry().getUrl());
     List<SchemaProvider> schemaProviders =
         List.of(new AvroSchemaProvider(), new ProtobufSchemaProvider(), new JsonSchemaProvider());
-    //TODO add auth
+
+    Map<String, String> configs = new HashMap<>();
+    String username = cluster.getSchemaRegistry().getUsername();
+    String password = cluster.getSchemaRegistry().getPassword();
+
+    if (username != null && password != null) {
+      configs.put(BASIC_AUTH_CREDENTIALS_SOURCE, "USER_INFO");
+      configs.put(USER_INFO_CONFIG, username + ":" + password);
+    } else if (username != null) {
+      throw new ValidationException(
+          "You specified username but do not specified password");
+    } else if (password != null) {
+      throw new ValidationException(
+          "You specified password but do not specified username");
+    }
     return new CachedSchemaRegistryClient(
-        Collections.singletonList(cluster.getSchemaRegistry()),
+        Collections.singletonList(cluster.getSchemaRegistry().getUrl()),
         CLIENT_IDENTITY_MAP_CAPACITY,
         schemaProviders,
-        Collections.emptyMap()
+        configs
     );
   }
 
@@ -181,7 +202,8 @@ public class SchemaRegistryAwareRecordSerDe implements RecordSerDe {
   private String convertSchema(SchemaMetadata schema) {
 
     String jsonSchema;
-    URI basePath = new URI(cluster.getSchemaRegistry()).resolve(Integer.toString(schema.getId()));
+    URI basePath = new URI(cluster.getSchemaRegistry().getUrl())
+        .resolve(Integer.toString(schema.getId()));
     final ParsedSchema schemaById = Objects.requireNonNull(schemaRegistryClient)
         .getSchemaById(schema.getId());
 

+ 76 - 25
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java

@@ -7,9 +7,11 @@ import com.provectus.kafka.ui.exception.ClusterNotFoundException;
 import com.provectus.kafka.ui.exception.DuplicateEntityException;
 import com.provectus.kafka.ui.exception.SchemaNotFoundException;
 import com.provectus.kafka.ui.exception.UnprocessableEntityException;
+import com.provectus.kafka.ui.exception.ValidationException;
 import com.provectus.kafka.ui.mapper.ClusterMapper;
 import com.provectus.kafka.ui.model.CompatibilityCheckResponse;
 import com.provectus.kafka.ui.model.CompatibilityLevel;
+import com.provectus.kafka.ui.model.InternalSchemaRegistry;
 import com.provectus.kafka.ui.model.KafkaCluster;
 import com.provectus.kafka.ui.model.NewSchemaSubject;
 import com.provectus.kafka.ui.model.SchemaSubject;
@@ -26,6 +28,8 @@ import java.util.function.Function;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.log4j.Log4j2;
 import org.jetbrains.annotations.NotNull;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
@@ -61,8 +65,10 @@ public class SchemaRegistryService {
 
   public Mono<String[]> getAllSubjectNames(String clusterName) {
     return clustersStorage.getClusterByName(clusterName)
-        .map(cluster -> webClient.get()
-            .uri(cluster.getSchemaRegistry() + URL_SUBJECTS)
+        .map(cluster -> configuredWebClient(
+            cluster,
+            HttpMethod.GET,
+            URL_SUBJECTS)
             .retrieve()
             .bodyToMono(String[].class)
             .doOnError(log::error)
@@ -77,8 +83,10 @@ public class SchemaRegistryService {
 
   private Flux<Integer> getSubjectVersions(String clusterName, String schemaName) {
     return clustersStorage.getClusterByName(clusterName)
-        .map(cluster -> webClient.get()
-            .uri(cluster.getSchemaRegistry() + URL_SUBJECT_VERSIONS, schemaName)
+        .map(cluster -> configuredWebClient(
+            cluster,
+            HttpMethod.GET,
+            URL_SUBJECT_VERSIONS, schemaName)
             .retrieve()
             .onStatus(NOT_FOUND::equals,
                 throwIfNotFoundStatus(formatted(NO_SUCH_SCHEMA, schemaName))
@@ -99,8 +107,10 @@ public class SchemaRegistryService {
   private Mono<SchemaSubject> getSchemaSubject(String clusterName, String schemaName,
                                                String version) {
     return clustersStorage.getClusterByName(clusterName)
-        .map(cluster -> webClient.get()
-            .uri(cluster.getSchemaRegistry() + URL_SUBJECT_BY_VERSION, schemaName, version)
+        .map(cluster -> configuredWebClient(
+            cluster,
+            HttpMethod.GET,
+            URL_SUBJECT_BY_VERSION, schemaName, version)
             .retrieve()
             .onStatus(NOT_FOUND::equals,
                 throwIfNotFoundStatus(formatted(NO_SUCH_SCHEMA_VERSION, schemaName, version))
@@ -140,8 +150,10 @@ public class SchemaRegistryService {
   private Mono<ResponseEntity<Void>> deleteSchemaSubject(String clusterName, String schemaName,
                                                          String version) {
     return clustersStorage.getClusterByName(clusterName)
-        .map(cluster -> webClient.delete()
-            .uri(cluster.getSchemaRegistry() + URL_SUBJECT_BY_VERSION, schemaName, version)
+        .map(cluster -> configuredWebClient(
+            cluster,
+            HttpMethod.DELETE,
+            URL_SUBJECT_BY_VERSION, schemaName, version)
             .retrieve()
             .onStatus(NOT_FOUND::equals,
                 throwIfNotFoundStatus(formatted(NO_SUCH_SCHEMA_VERSION, schemaName, version))
@@ -152,8 +164,10 @@ public class SchemaRegistryService {
   public Mono<ResponseEntity<Void>> deleteSchemaSubjectEntirely(String clusterName,
                                                                 String schemaName) {
     return clustersStorage.getClusterByName(clusterName)
-        .map(cluster -> webClient.delete()
-            .uri(cluster.getSchemaRegistry() + URL_SUBJECT, schemaName)
+        .map(cluster -> configuredWebClient(
+            cluster,
+            HttpMethod.DELETE,
+            URL_SUBJECT, schemaName)
             .retrieve()
             .onStatus(NOT_FOUND::equals,
                 throwIfNotFoundStatus(formatted(NO_SUCH_SCHEMA, schemaName))
@@ -178,8 +192,8 @@ public class SchemaRegistryService {
           return clustersStorage.getClusterByName(clusterName)
               .map(KafkaCluster::getSchemaRegistry)
               .map(
-                  schemaRegistryUrl -> checkSchemaOnDuplicate(subject, newSchema, schemaRegistryUrl)
-                      .flatMap(s -> submitNewSchema(subject, newSchema, schemaRegistryUrl))
+                  schemaRegistry -> checkSchemaOnDuplicate(subject, newSchema, schemaRegistry)
+                      .flatMap(s -> submitNewSchema(subject, newSchema, schemaRegistry))
                       .flatMap(resp -> getLatestSchemaVersionBySubject(clusterName, subject))
               )
               .orElse(Mono.error(ClusterNotFoundException::new));
@@ -189,9 +203,11 @@ public class SchemaRegistryService {
   @NotNull
   private Mono<SubjectIdResponse> submitNewSchema(String subject,
                                                   Mono<InternalNewSchema> newSchemaSubject,
-                                                  String schemaRegistryUrl) {
-    return webClient.post()
-        .uri(schemaRegistryUrl + URL_SUBJECT_VERSIONS, subject)
+                                                  InternalSchemaRegistry schemaRegistry) {
+    return configuredWebClient(
+        schemaRegistry,
+        HttpMethod.POST,
+        URL_SUBJECT_VERSIONS, subject)
         .contentType(MediaType.APPLICATION_JSON)
         .body(BodyInserters.fromPublisher(newSchemaSubject, InternalNewSchema.class))
         .retrieve()
@@ -204,9 +220,11 @@ public class SchemaRegistryService {
   @NotNull
   private Mono<SchemaSubject> checkSchemaOnDuplicate(String subject,
                                                      Mono<InternalNewSchema> newSchemaSubject,
-                                                     String schemaRegistryUrl) {
-    return webClient.post()
-        .uri(schemaRegistryUrl + URL_SUBJECT, subject)
+                                                     InternalSchemaRegistry schemaRegistry) {
+    return configuredWebClient(
+        schemaRegistry,
+        HttpMethod.POST,
+        URL_SUBJECT, subject)
         .contentType(MediaType.APPLICATION_JSON)
         .body(BodyInserters.fromPublisher(newSchemaSubject, InternalNewSchema.class))
         .retrieve()
@@ -236,8 +254,10 @@ public class SchemaRegistryService {
     return clustersStorage.getClusterByName(clusterName)
         .map(cluster -> {
           String configEndpoint = Objects.isNull(schemaName) ? "/config" : "/config/{schemaName}";
-          return webClient.put()
-              .uri(cluster.getSchemaRegistry() + configEndpoint, schemaName)
+          return configuredWebClient(
+              cluster,
+              HttpMethod.PUT,
+              configEndpoint, schemaName)
               .contentType(MediaType.APPLICATION_JSON)
               .body(BodyInserters.fromPublisher(compatibilityLevel, CompatibilityLevel.class))
               .retrieve()
@@ -257,8 +277,10 @@ public class SchemaRegistryService {
     return clustersStorage.getClusterByName(clusterName)
         .map(cluster -> {
           String configEndpoint = Objects.isNull(schemaName) ? "/config" : "/config/{schemaName}";
-          return webClient.get()
-              .uri(cluster.getSchemaRegistry() + configEndpoint, schemaName)
+          return configuredWebClient(
+              cluster,
+              HttpMethod.GET,
+              configEndpoint, schemaName)
               .retrieve()
               .bodyToMono(InternalCompatibilityLevel.class)
               .map(mapper::toCompatibilityLevel)
@@ -279,9 +301,10 @@ public class SchemaRegistryService {
   public Mono<CompatibilityCheckResponse> checksSchemaCompatibility(
       String clusterName, String schemaName, Mono<NewSchemaSubject> newSchemaSubject) {
     return clustersStorage.getClusterByName(clusterName)
-        .map(cluster -> webClient.post()
-            .uri(cluster.getSchemaRegistry()
-                + "/compatibility/subjects/{schemaName}/versions/latest", schemaName)
+        .map(cluster -> configuredWebClient(
+            cluster,
+            HttpMethod.POST,
+            "/compatibility/subjects/{schemaName}/versions/latest", schemaName)
             .contentType(MediaType.APPLICATION_JSON)
             .body(BodyInserters.fromPublisher(newSchemaSubject, NewSchemaSubject.class))
             .retrieve()
@@ -296,4 +319,32 @@ public class SchemaRegistryService {
   public String formatted(String str, Object... args) {
     return new Formatter().format(str, args).toString();
   }
+
+  private void setBasicAuthIfEnabled(InternalSchemaRegistry schemaRegistry, HttpHeaders headers) {
+    if (schemaRegistry.getUsername() != null && schemaRegistry.getPassword() != null) {
+      headers.setBasicAuth(
+          schemaRegistry.getUsername(),
+          schemaRegistry.getPassword()
+      );
+    } else if (schemaRegistry.getUsername() != null) {
+      throw new ValidationException(
+          "You specified username but do not specified password");
+    } else if (schemaRegistry.getPassword() != null) {
+      throw new ValidationException(
+          "You specified password but do not specified username");
+    }
+  }
+
+  private WebClient.RequestBodySpec configuredWebClient(KafkaCluster cluster, HttpMethod method,
+                                                        String uri, Object... params) {
+    return configuredWebClient(cluster.getSchemaRegistry(), method, uri, params);
+  }
+
+  private WebClient.RequestBodySpec configuredWebClient(InternalSchemaRegistry schemaRegistry,
+                                                        HttpMethod method, String uri,
+                                                        Object... params) {
+    return webClient.method(method)
+        .uri(schemaRegistry.getUrl() + uri, params)
+        .headers(headers -> setBasicAuthIfEnabled(schemaRegistry, headers));
+  }
 }

+ 71 - 40
kafka-ui-react-app/package-lock.json

@@ -8,7 +8,6 @@
       "version": "7.14.5",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz",
       "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==",
-      "dev": true,
       "requires": {
         "@babel/highlight": "^7.14.5"
       }
@@ -341,8 +340,7 @@
     "@babel/helper-validator-identifier": {
       "version": "7.14.5",
       "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz",
-      "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==",
-      "dev": true
+      "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg=="
     },
     "@babel/helper-validator-option": {
       "version": "7.14.5",
@@ -377,7 +375,6 @@
       "version": "7.14.5",
       "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz",
       "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==",
-      "dev": true,
       "requires": {
         "@babel/helper-validator-identifier": "^7.14.5",
         "chalk": "^2.0.0",
@@ -388,7 +385,6 @@
           "version": "3.2.1",
           "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
           "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-          "dev": true,
           "requires": {
             "color-convert": "^1.9.0"
           }
@@ -397,7 +393,6 @@
           "version": "2.4.2",
           "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
           "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-          "dev": true,
           "requires": {
             "ansi-styles": "^3.2.1",
             "escape-string-regexp": "^1.0.5",
@@ -407,14 +402,12 @@
         "has-flag": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
-          "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
-          "dev": true
+          "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
         },
         "supports-color": {
           "version": "5.5.0",
           "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
           "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-          "dev": true,
           "requires": {
             "has-flag": "^3.0.0"
           }
@@ -1346,7 +1339,6 @@
       "version": "7.14.6",
       "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.14.6.tgz",
       "integrity": "sha512-Xl8SPYtdjcMoCsIM4teyVRg7jIcgl8F2kRtoCcXuHzXswt9UxZCS6BzRo8fcnCuP6u2XtPgvyonmEPF57Kxo9Q==",
-      "dev": true,
       "requires": {
         "core-js-pure": "^3.14.0",
         "regenerator-runtime": "^0.13.4"
@@ -2118,7 +2110,6 @@
       "version": "27.0.6",
       "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz",
       "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==",
-      "dev": true,
       "requires": {
         "@types/istanbul-lib-coverage": "^2.0.0",
         "@types/istanbul-reports": "^3.0.0",
@@ -2131,7 +2122,6 @@
           "version": "16.0.4",
           "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
           "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
-          "dev": true,
           "requires": {
             "@types/yargs-parser": "*"
           }
@@ -2514,6 +2504,44 @@
         "loader-utils": "^2.0.0"
       }
     },
+    "@testing-library/dom": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.1.0.tgz",
+      "integrity": "sha512-kmW9alndr19qd6DABzQ978zKQ+J65gU2Rzkl8hriIetPnwpesRaK4//jEQyYh8fEALmGhomD/LBQqt+o+DL95Q==",
+      "requires": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^4.2.0",
+        "aria-query": "^4.2.2",
+        "chalk": "^4.1.0",
+        "dom-accessibility-api": "^0.5.6",
+        "lz-string": "^1.4.4",
+        "pretty-format": "^27.0.2"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+          "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
+        },
+        "pretty-format": {
+          "version": "27.0.6",
+          "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz",
+          "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==",
+          "requires": {
+            "@jest/types": "^27.0.6",
+            "ansi-regex": "^5.0.0",
+            "ansi-styles": "^5.0.0",
+            "react-is": "^17.0.1"
+          }
+        },
+        "react-is": {
+          "version": "17.0.2",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+          "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
+        }
+      }
+    },
     "@testing-library/jest-dom": {
       "version": "5.14.1",
       "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.14.1.tgz",
@@ -2543,6 +2571,15 @@
         }
       }
     },
+    "@testing-library/react": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.0.0.tgz",
+      "integrity": "sha512-sh3jhFgEshFyJ/0IxGltRhwZv2kFKfJ3fN1vTZ6hhMXzz9ZbbcTgmDYM4e+zJv+oiVKKEWZPyqPAh4MQBI65gA==",
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@testing-library/dom": "^8.0.0"
+      }
+    },
     "@tootallnate/once": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -2573,6 +2610,11 @@
       "integrity": "sha512-FTgBI767POY/lKNDNbIzgAX6miIDBs6NTCbdlDb8TrWovHsSvaVIZDlTqym29C6UqhzwcJx4CYr+AlrMywA0cA==",
       "dev": true
     },
+    "@types/aria-query": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
+      "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="
+    },
     "@types/babel__core": {
       "version": "7.1.14",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.14.tgz",
@@ -2726,14 +2768,12 @@
     "@types/istanbul-lib-coverage": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
-      "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==",
-      "dev": true
+      "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw=="
     },
     "@types/istanbul-lib-report": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
       "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
-      "dev": true,
       "requires": {
         "@types/istanbul-lib-coverage": "*"
       }
@@ -2742,7 +2782,6 @@
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
       "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
-      "dev": true,
       "requires": {
         "@types/istanbul-lib-report": "*"
       }
@@ -2790,8 +2829,7 @@
     "@types/node": {
       "version": "16.0.0",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.0.0.tgz",
-      "integrity": "sha512-TmCW5HoZ2o2/z2EYi109jLqIaPIi9y/lc2LmDCWzuCi35bcaQ+OtUh6nwBiFK7SOu25FAU5+YKdqFZUwtqGSdg==",
-      "dev": true
+      "integrity": "sha512-TmCW5HoZ2o2/z2EYi109jLqIaPIi9y/lc2LmDCWzuCi35bcaQ+OtUh6nwBiFK7SOu25FAU5+YKdqFZUwtqGSdg=="
     },
     "@types/node-fetch": {
       "version": "2.5.10",
@@ -2957,9 +2995,9 @@
       "dev": true
     },
     "@types/testing-library__jest-dom": {
-      "version": "5.14.0",
-      "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.0.tgz",
-      "integrity": "sha512-l2P2GO+hFF4Liye+fAajT1qBqvZOiL79YMpEvgGs1xTK7hECxBI8Wz4J7ntACJNiJ9r0vXQqYovroXRLPDja6A==",
+      "version": "5.14.1",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.1.tgz",
+      "integrity": "sha512-Gk9vaXfbzc5zCXI9eYE9BI5BNHEp4D3FWjgqBE/ePGYElLAP+KvxBcsdkwfIVvezs605oiyd/VrpiHe3Oeg+Aw==",
       "dev": true,
       "requires": {
         "@types/jest": "*"
@@ -3052,8 +3090,7 @@
     "@types/yargs-parser": {
       "version": "20.2.0",
       "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-20.2.0.tgz",
-      "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==",
-      "dev": true
+      "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA=="
     },
     "@typescript-eslint/eslint-plugin": {
       "version": "4.28.1",
@@ -3720,14 +3757,12 @@
     "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
+      "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
     },
     "ansi-styles": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
       "requires": {
         "color-convert": "^2.0.1"
       },
@@ -3736,7 +3771,6 @@
           "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"
           }
@@ -3744,8 +3778,7 @@
         "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
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
         }
       }
     },
@@ -3937,7 +3970,6 @@
       "version": "4.2.2",
       "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
       "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==",
-      "dev": true,
       "requires": {
         "@babel/runtime": "^7.10.2",
         "@babel/runtime-corejs3": "^7.10.2"
@@ -5293,7 +5325,6 @@
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
       "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
-      "dev": true,
       "requires": {
         "ansi-styles": "^4.1.0",
         "supports-color": "^7.1.0"
@@ -5968,8 +5999,7 @@
     "core-js-pure": {
       "version": "3.14.0",
       "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.14.0.tgz",
-      "integrity": "sha512-YVh+LN2FgNU0odThzm61BsdkwrbrchumFq3oztnE9vTKC4KS2fvnPmcx8t6jnqAyOTCTF4ZSiuK8Qhh7SNcL4g==",
-      "dev": true
+      "integrity": "sha512-YVh+LN2FgNU0odThzm61BsdkwrbrchumFq3oztnE9vTKC4KS2fvnPmcx8t6jnqAyOTCTF4ZSiuK8Qhh7SNcL4g=="
     },
     "core-util-is": {
       "version": "1.0.2",
@@ -6842,8 +6872,7 @@
     "dom-accessibility-api": {
       "version": "0.5.6",
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz",
-      "integrity": "sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==",
-      "dev": true
+      "integrity": "sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw=="
     },
     "dom-converter": {
       "version": "0.2.0",
@@ -7379,8 +7408,7 @@
     "escape-string-regexp": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
-      "dev": true
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
     },
     "escodegen": {
       "version": "2.0.0",
@@ -9484,8 +9512,7 @@
     "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
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
     },
     "has-symbols": {
       "version": "1.0.2",
@@ -12924,6 +12951,11 @@
         "yallist": "^4.0.0"
       }
     },
+    "lz-string": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
+      "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY="
+    },
     "magic-string": {
       "version": "0.25.7",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
@@ -18856,7 +18888,6 @@
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
       "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
       "requires": {
         "has-flag": "^4.0.0"
       }

+ 2 - 1
kafka-ui-react-app/package.json

@@ -8,6 +8,7 @@
     "@hookform/error-message": "^2.0.0",
     "@hookform/resolvers": "^2.5.1",
     "@rooks/use-outside-click-ref": "^4.10.1",
+    "@testing-library/react": "^12.0.0",
     "ace-builds": "^1.4.12",
     "bulma": "^0.9.3",
     "bulma-switch": "^2.0.0",
@@ -73,7 +74,7 @@
   "devDependencies": {
     "@jest/types": "^27.0.6",
     "@openapitools/openapi-generator-cli": "^2.3.5",
-    "@testing-library/jest-dom": "^5.11.10",
+    "@testing-library/jest-dom": "^5.14.1",
     "@types/classnames": "^2.2.11",
     "@types/enzyme": "^3.10.8",
     "@types/jest": "^26.0.21",

+ 4 - 0
kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx

@@ -30,6 +30,7 @@ const Overview: React.FC<Props> = ({
   segmentCount,
   clusterName,
   topicName,
+  cleanUpPolicy,
   clearTopicMessages,
 }) => {
   const { isReadOnly } = React.useContext(ClusterContext);
@@ -59,6 +60,9 @@ const Overview: React.FC<Props> = ({
           <BytesFormatted value={segmentSize} />
         </Indicator>
         <Indicator label="Segment count">{segmentCount}</Indicator>
+        <Indicator label="Clean Up Policy">
+          <span className="tag is-info">{cleanUpPolicy || 'Unknown'}</span>
+        </Indicator>
       </MetricsWrapper>
       <div className="box">
         <table className="table is-striped is-fullwidth">

+ 200 - 0
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone.tsx

@@ -0,0 +1,200 @@
+import { ErrorMessage } from '@hookform/error-message';
+import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
+import React from 'react';
+import { useForm } from 'react-hook-form';
+
+export interface Props {
+  clusterName: string;
+  topicName: string;
+  defaultPartitions: number;
+  defaultReplicationFactor: number;
+  partitionsCountIncreased: boolean;
+  replicationFactorUpdated: boolean;
+  updateTopicPartitionsCount: (
+    clusterName: string,
+    topicname: string,
+    partitions: number
+  ) => void;
+  updateTopicReplicationFactor: (
+    clusterName: string,
+    topicname: string,
+    replicationFactor: number
+  ) => void;
+}
+
+const DangerZone: React.FC<Props> = ({
+  clusterName,
+  topicName,
+  defaultPartitions,
+  defaultReplicationFactor,
+  partitionsCountIncreased,
+  replicationFactorUpdated,
+  updateTopicPartitionsCount,
+  updateTopicReplicationFactor,
+}) => {
+  const [isPartitionsConfirmationVisible, setIsPartitionsConfirmationVisible] =
+    React.useState<boolean>(false);
+  const [
+    isReplicationFactorConfirmationVisible,
+    setIsReplicationFactorConfirmationVisible,
+  ] = React.useState<boolean>(false);
+  const [partitions, setPartitions] = React.useState<number>(defaultPartitions);
+  const [replicationFactor, setReplicationFactor] = React.useState<number>(
+    defaultReplicationFactor
+  );
+
+  const {
+    register: partitionsRegister,
+    handleSubmit: handlePartitionsSubmit,
+    formState: partitionsFormState,
+    setError: setPartitionsError,
+    getValues: partitionsGetValues,
+  } = useForm({
+    defaultValues: {
+      partitions,
+    },
+  });
+
+  const {
+    register: replicationFactorRegister,
+    handleSubmit: handleКeplicationFactorSubmit,
+    formState: replicationFactorFormState,
+    getValues: replicationFactorgetValues,
+  } = useForm({
+    defaultValues: {
+      replicationFactor,
+    },
+  });
+
+  const validatePartitions = (data: { partitions: number }) => {
+    if (data.partitions < defaultPartitions) {
+      setPartitionsError('partitions', {
+        type: 'manual',
+        message: 'You can only increase the number of partitions!',
+      });
+    } else {
+      setPartitions(data.partitions);
+      setIsPartitionsConfirmationVisible(true);
+    }
+  };
+
+  const validateReplicationFactor = (data: { replicationFactor: number }) => {
+    setReplicationFactor(data.replicationFactor);
+    setIsReplicationFactorConfirmationVisible(true);
+  };
+
+  React.useEffect(() => {
+    if (partitionsCountIncreased) {
+      setIsPartitionsConfirmationVisible(false);
+    }
+  }, [partitionsCountIncreased]);
+
+  React.useEffect(() => {
+    if (replicationFactorUpdated) {
+      setIsReplicationFactorConfirmationVisible(false);
+    }
+  }, [replicationFactorUpdated]);
+
+  const partitionsSubmit = () => {
+    updateTopicPartitionsCount(
+      clusterName,
+      topicName,
+      partitionsGetValues('partitions')
+    );
+  };
+  const replicationFactorSubmit = () => {
+    updateTopicReplicationFactor(
+      clusterName,
+      topicName,
+      replicationFactorgetValues('replicationFactor')
+    );
+  };
+  return (
+    <div className="box">
+      <h4 className="title is-5 has-text-danger mb-5">Danger Zone</h4>
+      <div className="is-flex is-flex-direction-column">
+        <form
+          onSubmit={handlePartitionsSubmit(validatePartitions)}
+          className="columns mb-0"
+        >
+          <div className="column is-three-quarters">
+            <label className="label" htmlFor="partitions">
+              Number of partitions *
+            </label>
+            <input
+              className="input"
+              type="number"
+              id="partitions"
+              placeholder="Number of partitions"
+              {...partitionsRegister('partitions', {
+                required: 'Partiotions are required',
+              })}
+            />
+          </div>
+          <div className="column is-flex is-align-items-flex-end">
+            <input
+              type="submit"
+              className="button is-danger"
+              disabled={!partitionsFormState.isDirty}
+              data-testid="partitionsSubmit"
+            />
+          </div>
+        </form>
+        <p className="help is-danger mt-0 mb-4">
+          <ErrorMessage errors={partitionsFormState.errors} name="partitions" />
+        </p>
+        <ConfirmationModal
+          isOpen={isPartitionsConfirmationVisible}
+          onCancel={() => setIsPartitionsConfirmationVisible(false)}
+          onConfirm={partitionsSubmit}
+        >
+          Are you sure you want to increase the number of partitions? Do it only
+          if you 100% know what you are doing!
+        </ConfirmationModal>
+
+        <form
+          onSubmit={handleКeplicationFactorSubmit(validateReplicationFactor)}
+          className="columns"
+        >
+          <div className="column is-three-quarters">
+            <label className="label" htmlFor="replicationFactor">
+              Replication Factor *
+            </label>
+            <input
+              id="replicationFactor"
+              className="input"
+              type="number"
+              placeholder="Replication Factor"
+              {...replicationFactorRegister('replicationFactor', {
+                required: 'Replication Factor are required',
+              })}
+            />
+          </div>
+          <div className="column is-flex is-align-items-flex-end">
+            <input
+              type="submit"
+              className="button is-danger"
+              disabled={!replicationFactorFormState.isDirty}
+              data-testid="replicationFactorSubmit"
+            />
+          </div>
+        </form>
+        <p className="help is-danger mt-0">
+          <ErrorMessage
+            errors={replicationFactorFormState.errors}
+            name="replicationFactor"
+          />
+        </p>
+        <ConfirmationModal
+          isOpen={isReplicationFactorConfirmationVisible}
+          onCancel={() => setIsReplicationFactorConfirmationVisible(false)}
+          onConfirm={replicationFactorSubmit}
+        >
+          Are you sure you want to update the replication factor?
+        </ConfirmationModal>
+      </div>
+    </div>
+  );
+};
+
+export default DangerZone;

+ 50 - 0
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZoneContainer.ts

@@ -0,0 +1,50 @@
+import { connect } from 'react-redux';
+import { RootState, ClusterName, TopicName } from 'redux/interfaces';
+import { withRouter, RouteComponentProps } from 'react-router-dom';
+import {
+  updateTopicPartitionsCount,
+  updateTopicReplicationFactor,
+} from 'redux/actions';
+import {
+  getTopicPartitionsCountIncreased,
+  getTopicReplicationFactorUpdated,
+} from 'redux/reducers/topics/selectors';
+
+import DangerZone from './DangerZone';
+
+interface RouteProps {
+  clusterName: ClusterName;
+  topicName: TopicName;
+}
+
+type OwnProps = {
+  defaultPartitions: number;
+  defaultReplicationFactor: number;
+};
+
+const mapStateToProps = (
+  state: RootState,
+  {
+    match: {
+      params: { topicName, clusterName },
+    },
+    defaultPartitions,
+    defaultReplicationFactor,
+  }: OwnProps & RouteComponentProps<RouteProps>
+) => ({
+  clusterName,
+  topicName,
+  defaultPartitions,
+  defaultReplicationFactor,
+  partitionsCountIncreased: getTopicPartitionsCountIncreased(state),
+  replicationFactorUpdated: getTopicReplicationFactorUpdated(state),
+});
+
+const mapDispatchToProps = {
+  updateTopicPartitionsCount,
+  updateTopicReplicationFactor,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(DangerZone)
+);

+ 26 - 10
kafka-ui-react-app/src/components/Topics/Topic/Edit/Edit.tsx

@@ -13,6 +13,8 @@ import TopicForm from 'components/Topics/shared/Form/TopicForm';
 import { clusterTopicPath } from 'lib/paths';
 import { useHistory } from 'react-router';
 
+import DangerZoneContainer from './DangerZoneContainer';
+
 interface Props {
   clusterName: ClusterName;
   topicName: TopicName;
@@ -25,6 +27,11 @@ interface Props {
     topicName: TopicName,
     form: TopicFormDataRaw
   ) => void;
+  updateTopicPartitionsCount: (
+    clusterName: string,
+    topicname: string,
+    partitions: number
+  ) => void;
 }
 
 const DEFAULTS = {
@@ -112,17 +119,26 @@ const Edit: React.FC<Props> = ({
   };
 
   return (
-    <div className="box">
-      {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-      <FormProvider {...methods}>
-        <TopicForm
-          topicName={topicName}
-          config={config}
-          isSubmitting={isSubmitting}
-          isEditing
-          onSubmit={methods.handleSubmit(onSubmit)}
+    <div>
+      <div className="box">
+        <FormProvider {...methods}>
+          <TopicForm
+            topicName={topicName}
+            config={config}
+            isSubmitting={isSubmitting}
+            isEditing
+            onSubmit={methods.handleSubmit(onSubmit)}
+          />
+        </FormProvider>
+      </div>
+      {topic && (
+        <DangerZoneContainer
+          defaultPartitions={defaultValues.partitions}
+          defaultReplicationFactor={
+            defaultValues.replicationFactor || DEFAULTS.replicationFactor
+          }
         />
-      </FormProvider>
+      )}
     </div>
   );
 };

+ 68 - 0
kafka-ui-react-app/src/components/Topics/Topic/Edit/__tests__/DangerZone.spec.tsx

@@ -0,0 +1,68 @@
+import React from 'react';
+import DangerZone, { Props } from 'components/Topics/Topic/Edit/DangerZone';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+const setupWrapper = (props?: Partial<Props>) => (
+  <DangerZone
+    clusterName="testCluster"
+    topicName="testTopic"
+    defaultPartitions={3}
+    defaultReplicationFactor={3}
+    partitionsCountIncreased={false}
+    replicationFactorUpdated={false}
+    updateTopicPartitionsCount={jest.fn()}
+    updateTopicReplicationFactor={jest.fn()}
+    {...props}
+  />
+);
+
+describe('DangerZone', () => {
+  it('is rendered properly', () => {
+    const component = render(setupWrapper());
+    expect(component.baseElement).toMatchSnapshot();
+  });
+
+  it('calls updateTopicPartitionsCount', async () => {
+    const mockUpdateTopicPartitionsCount = jest.fn();
+    const component = render(
+      setupWrapper({
+        updateTopicPartitionsCount: mockUpdateTopicPartitionsCount,
+      })
+    );
+
+    const input = screen.getByLabelText('Number of partitions *');
+    fireEvent.input(input, {
+      target: {
+        value: 4,
+      },
+    });
+    fireEvent.submit(screen.getByTestId('partitionsSubmit'));
+    await waitFor(() => {
+      expect(component.baseElement).toMatchSnapshot();
+      fireEvent.click(screen.getByText('Confirm'));
+      expect(mockUpdateTopicPartitionsCount).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('calls updateTopicReplicationFactor', async () => {
+    const mockUpdateTopicReplicationFactor = jest.fn();
+    const component = render(
+      setupWrapper({
+        updateTopicReplicationFactor: mockUpdateTopicReplicationFactor,
+      })
+    );
+
+    const input = screen.getByLabelText('Replication Factor *');
+    fireEvent.input(input, {
+      target: {
+        value: 4,
+      },
+    });
+    fireEvent.submit(screen.getByTestId('replicationFactorSubmit'));
+    await waitFor(() => {
+      expect(component.baseElement).toMatchSnapshot();
+      fireEvent.click(screen.getByText('Confirm'));
+      expect(mockUpdateTopicReplicationFactor).toHaveBeenCalledTimes(1);
+    });
+  });
+});

+ 531 - 0
kafka-ui-react-app/src/components/Topics/Topic/Edit/__tests__/__snapshots__/DangerZone.spec.tsx.snap

@@ -0,0 +1,531 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DangerZone calls updateTopicPartitionsCount 1`] = `
+<body>
+  <div>
+    <div
+      class="box"
+    >
+      <h4
+        class="title is-5 has-text-danger mb-5"
+      >
+        Danger Zone
+      </h4>
+      <div
+        class="is-flex is-flex-direction-column"
+      >
+        <form
+          class="columns mb-0"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="partitions"
+            >
+              Number of partitions *
+            </label>
+            <input
+              class="input"
+              id="partitions"
+              name="partitions"
+              placeholder="Number of partitions"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="partitionsSubmit"
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0 mb-4"
+        />
+        <form
+          class="columns"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="replicationFactor"
+            >
+              Replication Factor *
+            </label>
+            <input
+              class="input"
+              id="replicationFactor"
+              name="replicationFactor"
+              placeholder="Replication Factor"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="replicationFactorSubmit"
+              disabled=""
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0"
+        />
+      </div>
+    </div>
+  </div>
+</body>
+`;
+
+exports[`DangerZone calls updateTopicPartitionsCount 2`] = `
+<body>
+  <div>
+    <div
+      class="box"
+    >
+      <h4
+        class="title is-5 has-text-danger mb-5"
+      >
+        Danger Zone
+      </h4>
+      <div
+        class="is-flex is-flex-direction-column"
+      >
+        <form
+          class="columns mb-0"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="partitions"
+            >
+              Number of partitions *
+            </label>
+            <input
+              class="input"
+              id="partitions"
+              name="partitions"
+              placeholder="Number of partitions"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="partitionsSubmit"
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0 mb-4"
+        />
+        <div
+          class="modal is-active"
+        >
+          <div
+            aria-hidden="true"
+            class="modal-background"
+          />
+          <div
+            class="modal-card"
+          >
+            <header
+              class="modal-card-head"
+            >
+              <p
+                class="modal-card-title"
+              >
+                Confirm the action
+              </p>
+              <button
+                aria-label="close"
+                class="delete"
+                type="button"
+              />
+            </header>
+            <section
+              class="modal-card-body"
+            >
+              Are you sure you want to increase the number of partitions? Do it only if you 100% know what you are doing!
+            </section>
+            <footer
+              class="modal-card-foot is-justify-content-flex-end"
+            >
+              <button
+                class="button is-danger"
+                type="button"
+              >
+                Confirm
+              </button>
+              <button
+                class="button"
+                type="button"
+              >
+                Cancel
+              </button>
+            </footer>
+          </div>
+        </div>
+        <form
+          class="columns"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="replicationFactor"
+            >
+              Replication Factor *
+            </label>
+            <input
+              class="input"
+              id="replicationFactor"
+              name="replicationFactor"
+              placeholder="Replication Factor"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="replicationFactorSubmit"
+              disabled=""
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0"
+        />
+      </div>
+    </div>
+  </div>
+</body>
+`;
+
+exports[`DangerZone calls updateTopicReplicationFactor 1`] = `
+<body>
+  <div>
+    <div
+      class="box"
+    >
+      <h4
+        class="title is-5 has-text-danger mb-5"
+      >
+        Danger Zone
+      </h4>
+      <div
+        class="is-flex is-flex-direction-column"
+      >
+        <form
+          class="columns mb-0"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="partitions"
+            >
+              Number of partitions *
+            </label>
+            <input
+              class="input"
+              id="partitions"
+              name="partitions"
+              placeholder="Number of partitions"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="partitionsSubmit"
+              disabled=""
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0 mb-4"
+        />
+        <form
+          class="columns"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="replicationFactor"
+            >
+              Replication Factor *
+            </label>
+            <input
+              class="input"
+              id="replicationFactor"
+              name="replicationFactor"
+              placeholder="Replication Factor"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="replicationFactorSubmit"
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0"
+        />
+      </div>
+    </div>
+  </div>
+</body>
+`;
+
+exports[`DangerZone calls updateTopicReplicationFactor 2`] = `
+<body>
+  <div>
+    <div
+      class="box"
+    >
+      <h4
+        class="title is-5 has-text-danger mb-5"
+      >
+        Danger Zone
+      </h4>
+      <div
+        class="is-flex is-flex-direction-column"
+      >
+        <form
+          class="columns mb-0"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="partitions"
+            >
+              Number of partitions *
+            </label>
+            <input
+              class="input"
+              id="partitions"
+              name="partitions"
+              placeholder="Number of partitions"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="partitionsSubmit"
+              disabled=""
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0 mb-4"
+        />
+        <form
+          class="columns"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="replicationFactor"
+            >
+              Replication Factor *
+            </label>
+            <input
+              class="input"
+              id="replicationFactor"
+              name="replicationFactor"
+              placeholder="Replication Factor"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="replicationFactorSubmit"
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0"
+        />
+        <div
+          class="modal is-active"
+        >
+          <div
+            aria-hidden="true"
+            class="modal-background"
+          />
+          <div
+            class="modal-card"
+          >
+            <header
+              class="modal-card-head"
+            >
+              <p
+                class="modal-card-title"
+              >
+                Confirm the action
+              </p>
+              <button
+                aria-label="close"
+                class="delete"
+                type="button"
+              />
+            </header>
+            <section
+              class="modal-card-body"
+            >
+              Are you sure you want to update the replication factor?
+            </section>
+            <footer
+              class="modal-card-foot is-justify-content-flex-end"
+            >
+              <button
+                class="button is-danger"
+                type="button"
+              >
+                Confirm
+              </button>
+              <button
+                class="button"
+                type="button"
+              >
+                Cancel
+              </button>
+            </footer>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</body>
+`;
+
+exports[`DangerZone is rendered properly 1`] = `
+<body>
+  <div>
+    <div
+      class="box"
+    >
+      <h4
+        class="title is-5 has-text-danger mb-5"
+      >
+        Danger Zone
+      </h4>
+      <div
+        class="is-flex is-flex-direction-column"
+      >
+        <form
+          class="columns mb-0"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="partitions"
+            >
+              Number of partitions *
+            </label>
+            <input
+              class="input"
+              id="partitions"
+              name="partitions"
+              placeholder="Number of partitions"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="partitionsSubmit"
+              disabled=""
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0 mb-4"
+        />
+        <form
+          class="columns"
+        >
+          <div
+            class="column is-three-quarters"
+          >
+            <label
+              class="label"
+              for="replicationFactor"
+            >
+              Replication Factor *
+            </label>
+            <input
+              class="input"
+              id="replicationFactor"
+              name="replicationFactor"
+              placeholder="Replication Factor"
+              type="number"
+            />
+          </div>
+          <div
+            class="column is-flex is-align-items-flex-end"
+          >
+            <input
+              class="button is-danger"
+              data-testid="replicationFactorSubmit"
+              disabled=""
+              type="submit"
+            />
+          </div>
+        </form>
+        <p
+          class="help is-danger mt-0"
+        />
+      </div>
+    </div>
+  </div>
+</body>
+`;

+ 20 - 18
kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx

@@ -32,7 +32,7 @@ const TopicForm: React.FC<Props> = ({
       <fieldset disabled={isSubmitting}>
         <fieldset disabled={isEditing}>
           <div className="columns">
-            <div className="column is-three-quarters">
+            <div className={`column ${isEditing ? '' : 'is-three-quarters'}`}>
               <label className="label">Topic Name *</label>
               <input
                 className="input"
@@ -52,26 +52,28 @@ const TopicForm: React.FC<Props> = ({
               </p>
             </div>
 
-            <div className="column">
-              <label className="label">Number of partitions *</label>
-              <input
-                className="input"
-                type="number"
-                placeholder="Number of partitions"
-                defaultValue="1"
-                {...register('partitions', {
-                  required: 'Number of partitions is required.',
-                })}
-              />
-              <p className="help is-danger">
-                <ErrorMessage errors={errors} name="partitions" />
-              </p>
-            </div>
+            {!isEditing && (
+              <div className="column">
+                <label className="label">Number of partitions *</label>
+                <input
+                  className="input"
+                  type="number"
+                  placeholder="Number of partitions"
+                  defaultValue="1"
+                  {...register('partitions', {
+                    required: 'Number of partitions is required.',
+                  })}
+                />
+                <p className="help is-danger">
+                  <ErrorMessage errors={errors} name="partitions" />
+                </p>
+              </div>
+            )}
           </div>
         </fieldset>
 
         <div className="columns">
-          <fieldset disabled={isEditing}>
+          {!isEditing && (
             <div className="column">
               <label className="label">Replication Factor *</label>
               <input
@@ -87,7 +89,7 @@ const TopicForm: React.FC<Props> = ({
                 <ErrorMessage errors={errors} name="replicationFactor" />
               </p>
             </div>
-          </fieldset>
+          )}
 
           <div className="column">
             <label className="label">Min In Sync Replicas *</label>

+ 80 - 0
kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts

@@ -3,6 +3,8 @@ import * as actions from 'redux/actions/actions';
 import * as thunks from 'redux/actions/thunks';
 import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
 import { mockTopicsState } from 'redux/actions/__test__/fixtures';
+import { FailurePayload } from 'redux/interfaces';
+import { getResponse } from 'lib/errorHandling';
 
 const store = mockStoreCreator;
 
@@ -132,4 +134,82 @@ describe('Thunks', () => {
       }
     });
   });
+
+  describe('increasing partitions count', () => {
+    it('calls updateTopicPartitionsCountAction.success on success', async () => {
+      fetchMock.patchOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}/partitions`,
+        { totalPartitionsCount: 4, topicName }
+      );
+      await store.dispatch(
+        thunks.updateTopicPartitionsCount(clusterName, topicName, 4)
+      );
+      expect(store.getActions()).toEqual([
+        actions.updateTopicPartitionsCountAction.request(),
+        actions.updateTopicPartitionsCountAction.success(),
+      ]);
+    });
+
+    it('calls updateTopicPartitionsCountAction.failure on failure', async () => {
+      fetchMock.patchOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}/partitions`,
+        404
+      );
+      try {
+        await store.dispatch(
+          thunks.updateTopicPartitionsCount(clusterName, topicName, 4)
+        );
+      } catch (error) {
+        const response = await getResponse(error);
+        const alert: FailurePayload = {
+          subject: ['topic-partitions', topicName].join('-'),
+          title: `Topic ${topicName} partitions count increase failed`,
+          response,
+        };
+        expect(store.getActions()).toEqual([
+          actions.updateTopicPartitionsCountAction.request(),
+          actions.updateTopicPartitionsCountAction.failure({ alert }),
+        ]);
+      }
+    });
+  });
+
+  describe('updating replication factor', () => {
+    it('calls updateTopicReplicationFactorAction.success on success', async () => {
+      fetchMock.patchOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}/replications`,
+        { totalReplicationFactor: 4, topicName }
+      );
+      await store.dispatch(
+        thunks.updateTopicReplicationFactor(clusterName, topicName, 4)
+      );
+      expect(store.getActions()).toEqual([
+        actions.updateTopicReplicationFactorAction.request(),
+        actions.updateTopicReplicationFactorAction.success(),
+      ]);
+    });
+
+    it('calls updateTopicReplicationFactorAction.failure on failure', async () => {
+      fetchMock.patchOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}/replications`,
+        404
+      );
+      try {
+        await store.dispatch(
+          thunks.updateTopicReplicationFactor(clusterName, topicName, 4)
+        );
+      } catch (error) {
+        const response = await getResponse(error);
+        const alert: FailurePayload = {
+          subject: ['topic-replication-factor', topicName].join('-'),
+          title: `Topic ${topicName} replication factor change failed`,
+          response,
+        };
+        expect(store.getActions()).toEqual([
+          actions.updateTopicReplicationFactorAction.request(),
+          actions.updateTopicReplicationFactorAction.failure({ alert }),
+        ]);
+      }
+    });
+  });
 });

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

@@ -253,3 +253,15 @@ export const fetchTopicConsumerGroupsAction = createAsyncAction(
   'GET_TOPIC_CONSUMER_GROUPS__SUCCESS',
   'GET_TOPIC_CONSUMER_GROUPS__FAILURE'
 )<undefined, TopicsState, undefined>();
+
+export const updateTopicPartitionsCountAction = createAsyncAction(
+  'UPDATE_PARTITIONS__REQUEST',
+  'UPDATE_PARTITIONS__SUCCESS',
+  'UPDATE_PARTITIONS__FAILURE'
+)<undefined, undefined, { alert?: FailurePayload }>();
+
+export const updateTopicReplicationFactorAction = createAsyncAction(
+  'UPDATE_REPLICATION_FACTOR__REQUEST',
+  'UPDATE_REPLICATION_FACTOR__SUCCESS',
+  'UPDATE_REPLICATION_FACTOR__FAILURE'
+)<undefined, undefined, { alert?: FailurePayload }>();

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

@@ -341,3 +341,55 @@ export const fetchTopicConsumerGroups =
       dispatch(actions.fetchTopicConsumerGroupsAction.failure());
     }
   };
+
+export const updateTopicPartitionsCount =
+  (
+    clusterName: ClusterName,
+    topicName: TopicName,
+    partitions: number
+  ): PromiseThunkResult =>
+  async (dispatch) => {
+    dispatch(actions.updateTopicPartitionsCountAction.request());
+    try {
+      await topicsApiClient.increaseTopicPartitions({
+        clusterName,
+        topicName,
+        partitionsIncrease: { totalPartitionsCount: partitions },
+      });
+      dispatch(actions.updateTopicPartitionsCountAction.success());
+    } catch (error) {
+      const response = await getResponse(error);
+      const alert: FailurePayload = {
+        subject: ['topic-partitions', topicName].join('-'),
+        title: `Topic ${topicName} partitions count increase failed`,
+        response,
+      };
+      dispatch(actions.updateTopicPartitionsCountAction.failure({ alert }));
+    }
+  };
+
+export const updateTopicReplicationFactor =
+  (
+    clusterName: ClusterName,
+    topicName: TopicName,
+    replicationFactor: number
+  ): PromiseThunkResult =>
+  async (dispatch) => {
+    dispatch(actions.updateTopicReplicationFactorAction.request());
+    try {
+      await topicsApiClient.changeReplicationFactor({
+        clusterName,
+        topicName,
+        replicationFactorChange: { totalReplicationFactor: replicationFactor },
+      });
+      dispatch(actions.updateTopicReplicationFactorAction.success());
+    } catch (error) {
+      const response = await getResponse(error);
+      const alert: FailurePayload = {
+        subject: ['topic-replication-factor', topicName].join('-'),
+        title: `Topic ${topicName} replication factor change failed`,
+        response,
+      };
+      dispatch(actions.updateTopicReplicationFactorAction.failure({ alert }));
+    }
+  };

+ 15 - 0
kafka-ui-react-app/src/redux/reducers/topics/selectors.ts

@@ -25,6 +25,11 @@ const getTopicMessagesFetchingStatus =
 const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
 const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
 const getTopicUpdateStatus = createFetchingSelector('PATCH_TOPIC');
+const getPartitionsCountIncreaseStatus =
+  createFetchingSelector('UPDATE_PARTITIONS');
+const getReplicationFactorUpdateStatus = createFetchingSelector(
+  'UPDATE_REPLICATION_FACTOR'
+);
 
 export const getAreTopicsFetching = createSelector(
   getTopicListFetchingStatus,
@@ -66,6 +71,16 @@ export const getTopicUpdated = createSelector(
   (status) => status === 'fetched'
 );
 
+export const getTopicPartitionsCountIncreased = createSelector(
+  getPartitionsCountIncreaseStatus,
+  (status) => status === 'fetched'
+);
+
+export const getTopicReplicationFactorUpdated = createSelector(
+  getReplicationFactorUpdateStatus,
+  (status) => status === 'fetched'
+);
+
 export const getTopicList = createSelector(
   getAreTopicsFetched,
   getAllNames,

+ 1 - 0
kafka-ui-react-app/src/setupTests.ts

@@ -1,6 +1,7 @@
 import { configure } from 'enzyme';
 import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
 import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
 
 configure({ adapter: new Adapter() });