Roman Nedzvetskiy 5 лет назад
Родитель
Сommit
3e212aa34f
27 измененных файлов с 802 добавлено и 191 удалено
  1. 136 29
      README.md
  2. 17 16
      docker/kafka-clusters-only.yaml
  3. 1 0
      images/free-open-source.svg
  4. BIN
      images/kafka-ui-interface-dashboard.png
  5. BIN
      images/kafka-ui-logo.png
  6. 8 0
      kafka-ui-api/pom.xml
  7. 24 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/config/SecurityConfig.java
  8. 1 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/model/InternalTopic.java
  9. 6 3
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/service/ClusterService.java
  10. 3 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/service/ConsumingService.java
  11. 22 0
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/kafka/KafkaService.java
  12. 10 0
      kafka-ui-api/src/main/resources/application-gauth.yml
  13. 3 1
      kafka-ui-api/src/main/resources/application-local.yml
  14. 12 0
      kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
  15. 3 1
      kafka-ui-react-app/.eslintrc.json
  16. 166 61
      kafka-ui-react-app/package-lock.json
  17. 7 2
      kafka-ui-react-app/package.json
  18. 4 0
      kafka-ui-react-app/src/components/App.scss
  19. 311 62
      kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx
  20. 13 3
      kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesContainer.ts
  21. 1 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamButton.tsx
  22. 0 4
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx
  23. 0 1
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamValue.tsx
  24. 8 2
      kafka-ui-react-app/src/redux/actions/thunks.ts
  25. 25 5
      kafka-ui-react-app/src/redux/api/topics.ts
  26. 15 1
      kafka-ui-react-app/src/redux/interfaces/topic.ts
  27. 6 0
      kafka-ui-react-app/src/redux/reducers/topics/selectors.ts

+ 136 - 29
README.md

@@ -1,49 +1,156 @@
-# Kafka-UI
+![Kafka UI logo](images/kafka-ui-logo.png) Kafka UI – Free Web UI for Kafka   
+------------------
 
-UI for Apache Kafka management
+![Kafka UI Price Free](images/free-open-source.svg)
 
-## Prerequisites
+<em>Kafka UI is a free open-source web UI for monitoring and management of Apache Kafka clusters. </em> 
+
+Kafka UI is a simple tool that makes your data flows observable, helps find and troubleshoot issues faster and deliver optimal performance. Its lightweight dashboard makes it easy to track key metrics of your Kafka clusters - Brokers, Topics, Partitions, Production, and Consumption. 
+
+Set up Kafka UI with just a couple of easy commands to visualize your Kafka data in a comprehensible way. You can run the tool locally or in the cloud. 
+
+![Kafka UI interface dashboard screenshot](images/kafka-ui-interface-dashboard.png)
+
+
+# Features
+* **Multi-Cluster Management** — monitor and manage all your clusters in one place
+* **Performance Monitoring with Metrics Dashboard** —  track key Kafka metrics with a lightweight dashboard
+* **View Kafka Brokers** — view topic and partition assignments, controller status
+* **View Kafka Topics** — view partition count, replication status, and custom configuration
+* **View Consumer Groups** — view per-partition parked offsets, combined and per-partition lag
+* **Browse Messages** — browse messages with JSON, plain text and Avro encoding
+* **Dynamic Topic Configuration** — create and configure new topics with dynamic configuration
+* **Configurable Authentification** — secure your installation with optional Github/Gitlab/Google OAuth 2.0
+ 
+
+# Getting Started
+
+To run Kafka UI, you can use a pre-built Docker image or build it locally.  
+
+## Running From Docker Image
+The official Docker image for Kafka UI is hosted here: [hub.docker.com/r/provectus/kafka-ui](https://hub.docker.com/r/provectus/kafka-ui).
+
+Launch Docker container in the background:
+```sh
+
+docker run -d {}/kafka-ui-api:latest 
+	-e KAFKA_CLUSTERS_0_NAME=local 
+	-e KAFKA_CLUSTERS_0__BOOTSTRAPSERVERS=kafka0:29092
 
-Install Homebrew Cask
 ```
+Then access the web UI at [http://localhost:9000](http://localhost:9000).
+ 
+
+## Building With Docker
+
+Steps to build Kafka UI locally with Docker:  
+
+1. Install prerequisites: Java and Docker
+2. Clone this repository and open a terminal in the directory of the project
+3. Build a Docker container with Kafka UI
+4. Start Kafka UI with your Kafka clusters
+5. Navigate to Kafka UI 
+
+### Prerequisites
+
+* Java 13 or newer
+* Docker 
+
+### Installing Prerequisites on Mac
+1. Install Homebrew Cask:
+```sh
 > brew update
 > brew cask
-```
-
-Install JAVA 13 with Homebrew Cask
-```
+``` 
+2. Install JAVA 13 with Homebrew Cask:
+```sh
 > brew tap homebrew/cask-versions
 > brew cask install java (or java13 if 13th version is not the latest one)
-```
+``` 
+### Building
 
+Once you installed the prerequisites and cloned the repository, run the following commands in your project directory: 
 
-## Getting started
+Build a Docker container with Kafka UI: 
+```sh
+./mvnw clean install -Pprod
+``` 
+Start Kafka UI with your Kafka clusters: 
+```sh
+docker-compose -f ./docker/kafka-ui.yaml up
+``` 
+To see Kafka UI, navigate to http://localhost:8080.
 
-Build application and docker container
+If you want to start only kafka-clusters: 
+```sh
+docker-compose -f ./docker/kafka-clusters-only.yaml up
+``` 
+Then start Kafka UI with a **local** profile. 
 
-```
-./mvnw clean install -Pprod
-```
+## Running Locally Without Docker
 
-Start application with kafka clusters
+```sh
+./mvnw spring-boot:run -Pprod
+``` 
 
-```
-docker-compose -f ./docker/kafka-ui.yaml up
-```
 
-Application should be available at http://localhost:8080.
+## Running in Kubernetes
+To be done
 
-To start only kafka-clusters:
+# Guides
 
-```
-docker-compose -f ./docker/kafka-clusters-only.yaml up
-```
+To be done
 
-Kafka-ui then should be started with **local** profile
+## Connecting to a Secure Broker
 
-### Run application without docker:
+Kafka UI supports TLS (SSL) and SASL connections for [encryption and authentication](http://kafka.apache.org/090/documentation.html#security). This can be configured by providing a combination of the following files (placed into the Kafka root directory):
+
+To be continued
+
+
+# Configuration
+
+## Configuration File
+Example of how to configure clusters in the [application-local.yml](https://github.com/provectus/kafka-ui/blob/master/kafka-ui-api/src/main/resources/application-local.yml) configuration file:
+
+
+```sh
+kafka:
+  clusters:
+    -
+      name: local
+      bootstrapServers: localhost:29091
+      zookeeper: localhost:2183
+      schemaRegistry: http://localhost:8085
+#     schemaNameTemplate: "%s-value"
+      jmxPort: 9997
+    -
+```    
+
+* `name`: cluster name
+* `bootstrapServers`: where to connect
+* `zookeeper`: zookeeper service address
+* `schemaRegistry`: schemaRegistry's address
+* `schemaNameTemplate`: how keys are saved to schemaRegistry
+* `jmxPort`: open jmxPosrts of a broker
+
+Configure as many clusters as you need by adding their configs below separated with `-`.
+
+## Environment Variables
+
+Alternatively, each variable of of the .yml file can be set with an environment variable. 
+For example, if you want to use an environment variable to set the `name` parameter, you can write it like this: `KAFKA_CLUSTERS_2_NAME`
+
+|Name               	|Description
+|-----------------------|-------------------------------
+|`KAFKA_CLUSTERS_2_NAME` | Cluster name
+|`KAFKA_CLUSTERS_2_BOOTSTRAPSERVERS` 	|Address where to connect 
+|`KAFKA_CLUSTERS_2_ZOOKEEPER` 	| Zookeper service address 
+|`KAFKA_CLUSTERS_2_SCHEMAREGISTRY`   	|SchemaRegistry's address
+|`KAFKA_CLUSTERS_2_SCHEMANAMETEMPLATE`  |How keys are saved to schemaRegistry
+|`KAFKA_CLUSTERS_2_JMXPORT`        	|Open jmxPosrts of a broker
+
+ 
+
+ 
 
-```
-cd kafka-ui-api
-./mvnw spring-boot:run -Pprod
-```

+ 17 - 16
docker/kafka-clusters-only.yaml

@@ -23,26 +23,26 @@ services:
       KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka0:9092,PLAINTEXT_HOST://localhost:29091 #,PLAIN://kafka0:29090
       KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT #,PLAIN:PLAINTEXT
       KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
-      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2
       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=localhost -Dcom.sun.management.jmxremote.rmi.port=9997
 
   kafka01:
-     image: confluentinc/cp-kafka:5.1.0
-     depends_on:
-       - zookeeper0
-     ports:
-       - 29093:29093
-       - 9999:9999
-     environment:
-       KAFKA_BROKER_ID: 2
-       KAFKA_ZOOKEEPER_CONNECT: zookeeper0:2183
-       KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka01:9092,PLAINTEXT_HOST://localhost:29093,PLAIN://kafka0:29090
-       KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,PLAIN:PLAINTEXT
-       KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
-       KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2
-       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=localhost -Dcom.sun.management.jmxremote.rmi.port=9997
+    image: confluentinc/cp-kafka:5.1.0
+    depends_on:
+      - zookeeper0
+    ports:
+      - 29093:29093
+      - 9999:9999
+    environment:
+      KAFKA_BROKER_ID: 2
+      KAFKA_ZOOKEEPER_CONNECT: zookeeper0:2183
+      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka01:9092,PLAINTEXT_HOST://localhost:29093,PLAIN://kafka0:29090
+      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,PLAIN:PLAINTEXT
+      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2
+      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=localhost -Dcom.sun.management.jmxremote.rmi.port=9997
 
   kafka-init-topics0:
     image: confluentinc/cp-kafka:5.1.0
@@ -79,6 +79,7 @@ services:
       KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka1:9092,PLAINTEXT_HOST://localhost:29092,PLAIN://localhost:29090
       KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,PLAIN: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=localhost -Dcom.sun.management.jmxremote.rmi.port=9998
 

+ 1 - 0
images/free-open-source.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="74" height="20"><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="74" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="37" height="20" fill="#0098f7"/><rect width="74" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">price</text><text x="195" y="140" transform="scale(.1)" textLength="270">price</text><text x="545" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">FREE</text><text x="545" y="140" transform="scale(.1)" textLength="270">FREE</text></g></svg>

BIN
images/kafka-ui-interface-dashboard.png


BIN
images/kafka-ui-logo.png


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

@@ -34,6 +34,14 @@
                 </exclusion>
             </exclusions>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-oauth2-client</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.provectus</groupId>
             <artifactId>kafka-ui-contract</artifactId>

+ 24 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/config/SecurityConfig.java

@@ -0,0 +1,24 @@
+package com.provectus.kafka.ui.cluster.config;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+
+@Configuration
+@EnableWebFluxSecurity
+@ConditionalOnProperty(value = "auth.enabled", havingValue = "false")
+public class SecurityConfig {
+
+	@Bean
+	public SecurityWebFilterChain configure(ServerHttpSecurity http) {
+		return http.authorizeExchange()
+				.anyExchange().permitAll()
+				.and()
+				.csrf().disable()
+				.build();
+	}
+
+}

+ 1 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/model/InternalTopic.java

@@ -1,5 +1,6 @@
 package com.provectus.kafka.ui.cluster.model;
 
+import com.provectus.kafka.ui.model.TopicPartitionDto;
 import lombok.Builder;
 import lombok.Data;
 import org.apache.kafka.common.TopicPartition;

+ 6 - 3
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/service/ClusterService.java

@@ -60,9 +60,12 @@ public class ClusterService {
 
     public Optional<TopicDetails> getTopicDetails(String name, String topicName) {
         return clustersStorage.getClusterByName(name)
-                .map(KafkaCluster::getTopics)
-                .map(t -> t.get(topicName))
-                .map(clusterMapper::toTopicDetails);
+                .map(c -> {
+                     var topic = c.getTopics().get(topicName);
+                     return clusterMapper
+                             .toTopicDetails(topic)
+                             .partitions(kafkaService.partitionDtoList(topic, c));
+                });
     }
                                                                            
     public Optional<List<TopicConfig>> getTopicConfigs(String name, String topicName) {

+ 3 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/cluster/service/ConsumingService.java

@@ -107,6 +107,9 @@ public class ConsumingService {
 				while (!sink.isCancelled() && ++pollsCount < MAX_POLLS_COUNT) {
 					ConsumerRecords<Bytes, Bytes> records = consumer.poll(POLL_TIMEOUT_MS);
 					log.info("{} records polled", records.count());
+					if (records.count() == 0) {
+						break;
+					}
 					records.iterator()
 							.forEachRemaining(sink::next);
 				}

+ 22 - 0
kafka-ui-api/src/main/java/com/provectus/kafka/ui/kafka/KafkaService.java

@@ -376,4 +376,26 @@ public class KafkaService {
                     .map(Optional::get)
                     .collect(Collectors.toList())).build();
     }
+
+    public List<TopicPartitionDto> partitionDtoList (InternalTopic topic, KafkaCluster cluster) {
+        var topicPartitions = topic.getPartitions().stream().map(t -> new TopicPartition(topic.getName(), t.getPartition())).collect(Collectors.toList());
+        return getTopicPartitionOffset(cluster, topicPartitions);
+    }
+
+    private List<TopicPartitionDto> getTopicPartitionOffset(KafkaCluster c, List<TopicPartition> topicPartitions )  {
+        try (var consumer = createConsumer(c)) {
+            final Map<TopicPartition, Long> earliest = consumer.beginningOffsets(topicPartitions);
+            final Map<TopicPartition, Long> latest = consumer.endOffsets(topicPartitions);
+
+            return topicPartitions.stream()
+                    .map( tp -> new TopicPartitionDto()
+                            .topic(tp.topic())
+                            .partition(tp.partition())
+                            .offsetMin(Optional.ofNullable(earliest.get(tp)).orElse(0L))
+                            .offsetMax(Optional.ofNullable(latest.get(tp)).orElse(0L))
+                    ).collect(Collectors.toList());
+        } catch (Exception e) {
+            return Collections.emptyList();
+        }
+    }
 }

+ 10 - 0
kafka-ui-api/src/main/resources/application-gauth.yml

@@ -0,0 +1,10 @@
+auth:
+  enabled: true
+spring:
+  security:
+    oauth2:
+      client:
+        registration:
+          google:
+            client-id: [put your client id here]
+            client-secret: [put your client secret here]

+ 3 - 1
kafka-ui-api/src/main/resources/application-local.yml

@@ -22,4 +22,6 @@ zookeeper:
   connection-timeout: 1000
 spring:
   jmx:
-    enabled: true
+    enabled: true
+auth:
+  enabled: false

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

@@ -388,6 +388,10 @@ components:
     TopicDetails:
       type: object
       properties:
+        partitions:
+          type: array
+          items:
+            $ref: "#/components/schemas/TopicPartitionDto"
         partitionCount:
           type: integer
         replicationFactor:
@@ -493,9 +497,17 @@ components:
           type: string
         partition:
           type: integer
+        offsetMax:
+          type: integer
+          format: int64
+        offsetMin:
+          type: integer
+          format: int64
       required:
         - topic
         - partition
+        - offsetMax
+        - offsetMin
 
     ConsumerTopicPartitionDetail:
       type: object

+ 3 - 1
kafka-ui-react-app/.eslintrc.json

@@ -24,6 +24,7 @@
     "plugin:@typescript-eslint/recommended"
   ],
   "rules": {
+    "@typescript-eslint/ban-ts-ignore": "off",
     "import/extensions": [
       "error",
       "ignorePackages",
@@ -56,7 +57,8 @@
       "node": {
         "extensions": [".js", ".jsx", ".ts", ".tsx"],
         "paths": ["src"]
-      }
+      },
+      "typescript": {}
     }
   }
 }

+ 166 - 61
kafka-ui-react-app/package-lock.json

@@ -1556,6 +1556,11 @@
       "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==",
       "dev": true
     },
+    "@rooks/use-outside-click": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/@rooks/use-outside-click/-/use-outside-click-3.6.0.tgz",
+      "integrity": "sha512-DDxdcD9bDDArV2tBmh5okaJNee/7EWaC5DsPrjTxIhhvXPpUatizcn2qYLcvX7y1vYpd64Wyqvkb87E6fsIfEQ=="
+    },
     "@samverschueren/stream-to-observable": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz",
@@ -1889,6 +1894,11 @@
       "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==",
       "dev": true
     },
+    "@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4="
+    },
     "@types/lodash": {
       "version": "4.14.149",
       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
@@ -1916,8 +1926,7 @@
     "@types/prop-types": {
       "version": "15.7.3",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
-      "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
-      "dev": true
+      "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
     },
     "@types/q": {
       "version": "1.5.4",
@@ -1929,12 +1938,21 @@
       "version": "16.9.17",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.17.tgz",
       "integrity": "sha512-UP27In4fp4sWF5JgyV6pwVPAQM83Fj76JOcg02X5BZcpSu5Wx+fP9RMqc2v0ssBoQIFvD5JdKY41gjJJKmw6Bg==",
-      "dev": true,
       "requires": {
         "@types/prop-types": "*",
         "csstype": "^2.2.0"
       }
     },
+    "@types/react-datepicker": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-3.0.2.tgz",
+      "integrity": "sha512-xW04NZRF+9ZnzOD3XrlIzBEKgUsN6LVgZJJsXH8NIUlVjyPh+sdtLPfVoDp+GQzGq1M0TuMLNZsv0sJ3N9XwDA==",
+      "requires": {
+        "@types/react": "*",
+        "date-fns": "^2.0.1",
+        "popper.js": "^1.14.1"
+      }
+    },
     "@types/react-dom": {
       "version": "16.9.4",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.4.tgz",
@@ -4718,6 +4736,15 @@
         "sha.js": "^2.4.8"
       }
     },
+    "create-react-context": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.3.0.tgz",
+      "integrity": "sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==",
+      "requires": {
+        "gud": "^1.0.0",
+        "warning": "^4.0.3"
+      }
+    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -5041,8 +5068,7 @@
     "csstype": {
       "version": "2.6.8",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.8.tgz",
-      "integrity": "sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA==",
-      "dev": true
+      "integrity": "sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA=="
     },
     "currently-unhandled": {
       "version": "0.4.1",
@@ -5117,7 +5143,6 @@
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
       "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-      "dev": true,
       "requires": {
         "ms": "^2.1.1"
       }
@@ -5153,7 +5178,6 @@
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
       "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
-      "dev": true,
       "requires": {
         "is-arguments": "^1.0.4",
         "is-date-object": "^1.0.1",
@@ -5204,7 +5228,6 @@
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
       "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
-      "dev": true,
       "requires": {
         "object-keys": "^1.0.12"
       }
@@ -5788,7 +5811,6 @@
       "version": "1.17.0-next.1",
       "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.0-next.1.tgz",
       "integrity": "sha512-7MmGr03N7Rnuid6+wyhD9sHNE2n4tFSwExnU2lQl3lIo2ShXWGePY80zYaoMOmILWv57H0amMjZGHNzzGG70Rw==",
-      "dev": true,
       "requires": {
         "es-to-primitive": "^1.2.1",
         "function-bind": "^1.1.1",
@@ -5807,7 +5829,6 @@
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
       "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
-      "dev": true,
       "requires": {
         "is-callable": "^1.1.4",
         "is-date-object": "^1.0.1",
@@ -6042,6 +6063,18 @@
         }
       }
     },
+    "eslint-import-resolver-typescript": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.0.0.tgz",
+      "integrity": "sha512-bT5Frpl8UWoHBtY25vKUOMoVIMlJQOMefHLyQ4Tz3MQpIZ2N6yYKEEIHMo38bszBNUuMBW6M3+5JNYxeiGFH4w==",
+      "requires": {
+        "debug": "^4.1.1",
+        "is-glob": "^4.0.1",
+        "resolve": "^1.12.0",
+        "tiny-glob": "^0.2.6",
+        "tsconfig-paths": "^3.9.0"
+      }
+    },
     "eslint-loader": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/eslint-loader/-/eslint-loader-3.0.3.tgz",
@@ -7507,8 +7540,7 @@
     "function-bind": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
-      "dev": true
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
     },
     "functional-red-black-tree": {
       "version": "1.0.1",
@@ -7715,6 +7747,11 @@
       "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
       "dev": true
     },
+    "globalyzer": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.4.tgz",
+      "integrity": "sha512-LeguVWaxgHN0MNbWC6YljNMzHkrCny9fzjmEUdnF1kQ7wATFD1RHFRqA1qxaX2tgxGENlcxjOflopBwj3YZiXA=="
+    },
     "globby": {
       "version": "8.0.2",
       "resolved": "https://registry.npmjs.org/globby/-/globby-8.0.2.tgz",
@@ -7744,6 +7781,11 @@
         }
       }
     },
+    "globrex": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
+      "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
+    },
     "globule": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.0.tgz",
@@ -7755,6 +7797,11 @@
         "minimatch": "~3.0.2"
       }
     },
+    "goober": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/goober/-/goober-1.8.0.tgz",
+      "integrity": "sha512-9ZFoOkBccexjqIgcwlhq7C/eCSkgTZX0BdNUkOnBFLedrJgo3R8lp9ckd/qqtngtF/JDyXSxJzwMU98kNjZ4Mw=="
+    },
     "got": {
       "version": "9.6.0",
       "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
@@ -7841,7 +7888,6 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
       "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
-      "dev": true,
       "requires": {
         "function-bind": "^1.1.1"
       }
@@ -7872,8 +7918,7 @@
     "has-symbols": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
-      "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
-      "dev": true
+      "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg=="
     },
     "has-unicode": {
       "version": "2.0.1",
@@ -8711,8 +8756,7 @@
     "is-arguments": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
-      "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==",
-      "dev": true
+      "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA=="
     },
     "is-arrayish": {
       "version": "0.2.1",
@@ -8739,8 +8783,7 @@
     "is-callable": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
-      "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
-      "dev": true
+      "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA=="
     },
     "is-ci": {
       "version": "2.0.0",
@@ -8777,8 +8820,7 @@
     "is-date-object": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
-      "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=",
-      "dev": true
+      "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY="
     },
     "is-descriptor": {
       "version": "0.1.6",
@@ -8820,8 +8862,7 @@
     "is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
-      "dev": true
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
     },
     "is-finite": {
       "version": "1.0.2",
@@ -8848,7 +8889,6 @@
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
       "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
-      "dev": true,
       "requires": {
         "is-extglob": "^2.1.1"
       }
@@ -8944,7 +8984,6 @@
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
       "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
-      "dev": true,
       "requires": {
         "has": "^1.0.3"
       }
@@ -8992,7 +9031,6 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
       "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
-      "dev": true,
       "requires": {
         "has-symbols": "^1.0.1"
       }
@@ -11209,8 +11247,7 @@
     "minimist": {
       "version": "1.2.5",
       "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
-      "dev": true
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
     },
     "minipass": {
       "version": "3.1.3",
@@ -11361,8 +11398,7 @@
     "ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-      "dev": true
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
     },
     "multicast-dns": {
       "version": "6.2.3",
@@ -11866,14 +11902,12 @@
     "object-inspect": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
-      "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==",
-      "dev": true
+      "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw=="
     },
     "object-is": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz",
       "integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.3",
         "es-abstract": "^1.17.5"
@@ -11883,7 +11917,6 @@
           "version": "1.17.5",
           "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
           "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
-          "dev": true,
           "requires": {
             "es-to-primitive": "^1.2.1",
             "function-bind": "^1.1.1",
@@ -11901,14 +11934,12 @@
         "is-callable": {
           "version": "1.1.5",
           "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
-          "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==",
-          "dev": true
+          "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
         },
         "string.prototype.trimleft": {
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
           "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
-          "dev": true,
           "requires": {
             "define-properties": "^1.1.3",
             "es-abstract": "^1.17.5",
@@ -11919,7 +11950,6 @@
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
           "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
-          "dev": true,
           "requires": {
             "define-properties": "^1.1.3",
             "es-abstract": "^1.17.5",
@@ -11931,8 +11961,7 @@
     "object-keys": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
-      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
-      "dev": true
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
     },
     "object-path": {
       "version": "0.11.4",
@@ -11953,7 +11982,6 @@
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
       "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.2",
         "function-bind": "^1.1.1",
@@ -12431,8 +12459,7 @@
     "path-parse": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
-      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
-      "dev": true
+      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
     },
     "path-to-regexp": {
       "version": "0.1.7",
@@ -12597,6 +12624,11 @@
         "ts-pnp": "^1.1.2"
       }
     },
+    "popper.js": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
+      "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
+    },
     "portfinder": {
       "version": "1.0.26",
       "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.26.tgz",
@@ -14023,6 +14055,18 @@
         "whatwg-fetch": "^3.0.0"
       }
     },
+    "react-datepicker": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-3.0.0.tgz",
+      "integrity": "sha512-Yrxan1tERAiWS0EzitpiaiXOIz0APTUtV75uWbaS+jSaKoGCR6wUN2FDwr1ACGlnEoGhR9QQ2Vq3odnWtgJsOA==",
+      "requires": {
+        "classnames": "^2.2.6",
+        "date-fns": "^2.0.1",
+        "prop-types": "^15.7.2",
+        "react-onclickoutside": "^6.9.0",
+        "react-popper": "^1.3.4"
+      }
+    },
     "react-dev-utils": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz",
@@ -14300,6 +14344,34 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
       "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
     },
+    "react-multi-select-component": {
+      "version": "2.0.12",
+      "resolved": "https://registry.npmjs.org/react-multi-select-component/-/react-multi-select-component-2.0.12.tgz",
+      "integrity": "sha512-QcOc8zQgz9AQQkX51EuDokqPi8BIRGBQdvnn1im3d1gsSIIY2W09jkvd9+/ByVk6NiL4XjygJtwCGJSGQcr3+A==",
+      "requires": {
+        "@rooks/use-outside-click": "^3.6.0",
+        "goober": "^1.8.0"
+      }
+    },
+    "react-onclickoutside": {
+      "version": "6.9.0",
+      "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.9.0.tgz",
+      "integrity": "sha512-8ltIY3bC7oGhj2nPAvWOGi+xGFybPNhJM0V1H8hY/whNcXgmDeaeoCMPPd8VatrpTsUWjb/vGzrmu6SrXVty3A=="
+    },
+    "react-popper": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz",
+      "integrity": "sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==",
+      "requires": {
+        "@babel/runtime": "^7.1.2",
+        "create-react-context": "^0.3.0",
+        "deep-equal": "^1.1.1",
+        "popper.js": "^1.14.4",
+        "prop-types": "^15.6.1",
+        "typed-styles": "^0.0.7",
+        "warning": "^4.0.2"
+      }
+    },
     "react-redux": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.3.tgz",
@@ -14819,7 +14891,6 @@
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz",
       "integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.3",
         "es-abstract": "^1.17.0-next.1"
@@ -15071,7 +15142,6 @@
       "version": "1.12.2",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.2.tgz",
       "integrity": "sha512-cAVTI2VLHWYsGOirfeYVVQ7ZDejtQ9fp4YhYckWDEkFfqbVjaT11iM8k6xSAfGFMM+gDpZjMnFssPu8we+mqFw==",
-      "dev": true,
       "requires": {
         "path-parse": "^1.0.6"
       }
@@ -16682,7 +16752,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
       "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.3",
         "es-abstract": "^1.17.5"
@@ -16692,7 +16761,6 @@
           "version": "1.17.5",
           "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
           "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
-          "dev": true,
           "requires": {
             "es-to-primitive": "^1.2.1",
             "function-bind": "^1.1.1",
@@ -16710,14 +16778,12 @@
         "is-callable": {
           "version": "1.1.5",
           "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
-          "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==",
-          "dev": true
+          "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
         },
         "string.prototype.trimleft": {
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
           "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
-          "dev": true,
           "requires": {
             "define-properties": "^1.1.3",
             "es-abstract": "^1.17.5",
@@ -16728,7 +16794,6 @@
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
           "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
-          "dev": true,
           "requires": {
             "define-properties": "^1.1.3",
             "es-abstract": "^1.17.5",
@@ -16741,7 +16806,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz",
       "integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.3",
         "function-bind": "^1.1.1"
@@ -16751,7 +16815,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz",
       "integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.3",
         "function-bind": "^1.1.1"
@@ -16761,7 +16824,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz",
       "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==",
-      "dev": true,
       "requires": {
         "define-properties": "^1.1.3",
         "es-abstract": "^1.17.5"
@@ -16771,7 +16833,6 @@
           "version": "1.17.5",
           "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
           "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
-          "dev": true,
           "requires": {
             "es-to-primitive": "^1.2.1",
             "function-bind": "^1.1.1",
@@ -16789,14 +16850,12 @@
         "is-callable": {
           "version": "1.1.5",
           "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
-          "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==",
-          "dev": true
+          "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
         },
         "string.prototype.trimleft": {
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
           "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
-          "dev": true,
           "requires": {
             "define-properties": "^1.1.3",
             "es-abstract": "^1.17.5",
@@ -16807,7 +16866,6 @@
           "version": "2.1.2",
           "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
           "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
-          "dev": true,
           "requires": {
             "define-properties": "^1.1.3",
             "es-abstract": "^1.17.5",
@@ -16856,8 +16914,7 @@
     "strip-bom": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
-      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
-      "dev": true
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM="
     },
     "strip-comments": {
       "version": "1.0.2",
@@ -17332,6 +17389,15 @@
       "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=",
       "dev": true
     },
+    "tiny-glob": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.6.tgz",
+      "integrity": "sha512-A7ewMqPu1B5PWwC3m7KVgAu96Ch5LA0w4SnEN/LbDREj/gAD0nPWboRbn8YoP9ISZXqeNAlMvKSKoEuhcfK3Pw==",
+      "requires": {
+        "globalyzer": "^0.1.0",
+        "globrex": "^0.1.1"
+      }
+    },
     "tiny-invariant": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz",
@@ -17458,6 +17524,27 @@
       "integrity": "sha512-ti7OGMOUOzo66wLF3liskw6YQIaSsBgc4GOAlWRnIEj8htCxJUxskanMUoJOD6MDCRAXo36goXJZch+nOS0VMA==",
       "dev": true
     },
+    "tsconfig-paths": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz",
+      "integrity": "sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==",
+      "requires": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.0",
+        "strip-bom": "^3.0.0"
+      },
+      "dependencies": {
+        "json5": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+          "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+          "requires": {
+            "minimist": "^1.2.0"
+          }
+        }
+      }
+    },
     "tslib": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
@@ -17525,6 +17612,11 @@
         "mime-types": "~2.1.24"
       }
     },
+    "typed-styles": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz",
+      "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q=="
+    },
     "typedarray": {
       "version": "0.0.6",
       "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -17782,6 +17874,11 @@
       "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
       "dev": true
     },
+    "use-debounce": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-3.4.3.tgz",
+      "integrity": "sha512-nxy+opOxDccWfhMl36J5BSCTpvcj89iaQk2OZWLAtBJQj7ISCtx1gh+rFbdjGfMl6vtCZf6gke/kYvrkVfHMoA=="
+    },
     "util": {
       "version": "0.10.3",
       "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
@@ -17969,6 +18066,14 @@
         "makeerror": "1.0.x"
       }
     },
+    "warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "requires": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "watchpack": {
       "version": "1.7.2",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.2.tgz",

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

@@ -3,22 +3,27 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@types/react-datepicker": "^3.0.2",
     "bulma": "^0.8.0",
     "bulma-switch": "^2.0.0",
     "classnames": "^2.2.6",
-    "immer": "^6.0.5",
     "date-fns": "^2.14.0",
+    "eslint-import-resolver-typescript": "^2.0.0",
+    "immer": "^6.0.5",
     "lodash": "^4.17.15",
     "pretty-ms": "^6.0.1",
     "react": "^16.12.0",
+    "react-datepicker": "^3.0.0",
     "react-dom": "^16.12.0",
     "react-hook-form": "^4.5.5",
+    "react-multi-select-component": "^2.0.12",
     "react-redux": "^7.1.3",
     "react-router-dom": "^5.1.2",
     "redux": "^4.0.5",
     "redux-thunk": "^2.3.0",
     "reselect": "^4.0.0",
-    "typesafe-actions": "^5.1.0"
+    "typesafe-actions": "^5.1.0",
+    "use-debounce": "^3.4.3"
   },
   "lint-staged": {
     "*.{js,ts,jsx,tsx}": [

+ 4 - 0
kafka-ui-react-app/src/components/App.scss

@@ -28,3 +28,7 @@ $navbar-width: 250px;
     overflow-y: scroll;
   }
 }
+
+.react-datepicker-wrapper {
+  display: flex !important;
+}

+ 311 - 62
kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx

@@ -1,14 +1,52 @@
-import React from 'react';
-import { ClusterName, TopicMessage, TopicName } from 'redux/interfaces';
+import React, { useEffect, useRef } from 'react';
+import {
+  ClusterName,
+  SeekType,
+  SeekTypes,
+  TopicMessage,
+  TopicMessageQueryParams,
+  TopicName,
+  TopicPartition,
+} from 'redux/interfaces';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import { format } from 'date-fns';
+import DatePicker from 'react-datepicker';
+
+import 'react-datepicker/dist/react-datepicker.css';
+import CustomParamButton, {
+  CustomParamButtonType,
+} from 'components/Topics/shared/Form/CustomParams/CustomParamButton';
+
+import MultiSelect from 'react-multi-select-component';
+
+import * as _ from 'lodash';
+import { useDebouncedCallback } from 'use-debounce';
+import { Option } from 'react-multi-select-component/dist/lib/interfaces';
 
 interface Props {
   clusterName: ClusterName;
   topicName: TopicName;
   isFetched: boolean;
-  fetchTopicMessages: (clusterName: ClusterName, topicName: TopicName) => void;
+  fetchTopicMessages: (
+    clusterName: ClusterName,
+    topicName: TopicName,
+    queryParams: Partial<TopicMessageQueryParams>
+  ) => void;
   messages: TopicMessage[];
+  partitions: TopicPartition[];
+}
+
+interface FilterProps {
+  offset: number;
+  partition: number;
+}
+
+function usePrevious(value: any) {
+  const ref = useRef();
+  useEffect(() => {
+    ref.current = value;
+  });
+  return ref.current;
 }
 
 const Messages: React.FC<Props> = ({
@@ -16,90 +54,301 @@ const Messages: React.FC<Props> = ({
   clusterName,
   topicName,
   messages,
+  partitions,
   fetchTopicMessages,
 }) => {
+  const [searchQuery, setSearchQuery] = React.useState<string>('');
+  const [searchTimestamp, setSearchTimestamp] = React.useState<Date | null>(
+    null
+  );
+  const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
+  const [selectedSeekType, setSelectedSeekType] = React.useState<SeekType>(
+    SeekTypes.OFFSET
+  );
+  const [searchOffset, setSearchOffset] = React.useState<string>('0');
+  const [selectedPartitions, setSelectedPartitions] = React.useState<Option[]>(
+    partitions.map((p) => ({
+      value: p.partition,
+      label: p.partition.toString(),
+    }))
+  );
+  const [queryParams, setQueryParams] = React.useState<
+    Partial<TopicMessageQueryParams>
+  >({ limit: 100 });
+  const [debouncedCallback] = useDebouncedCallback(
+    (query: any) => setQueryParams({ ...queryParams, ...query }),
+    1000
+  );
+
+  const prevSearchTimestamp = usePrevious(searchTimestamp);
+
+  const getUniqueDataForEachPartition: FilterProps[] = React.useMemo(() => {
+    const partitionUniqs: FilterProps[] = partitions.map((p) => ({
+      offset: 0,
+      partition: p.partition,
+    }));
+    const messageUniqs: FilterProps[] = _.map(
+      _.groupBy(messages, 'partition'),
+      (v) => _.maxBy(v, 'offset')
+    ).map((v) => ({
+      offset: v ? v.offset : 0,
+      partition: v ? v.partition : 0,
+    }));
+
+    return _.map(
+      _.groupBy(_.concat(partitionUniqs, messageUniqs), 'partition'),
+      (v) => _.maxBy(v, 'offset') as FilterProps
+    );
+  }, [messages, partitions]);
+
+  const getSeekToValuesForPartitions = (partition: any) => {
+    const foundedValues = filterProps.find(
+      (prop) => prop.partition === partition.value
+    );
+    if (selectedSeekType === SeekTypes.OFFSET) {
+      return foundedValues ? foundedValues.offset : 0;
+    }
+    return searchTimestamp ? searchTimestamp.getTime() : null;
+  };
+
   React.useEffect(() => {
-    fetchTopicMessages(clusterName, topicName);
-  }, [fetchTopicMessages, clusterName, topicName]);
+    fetchTopicMessages(clusterName, topicName, queryParams);
+  }, [fetchTopicMessages, clusterName, topicName, queryParams]);
+
+  React.useEffect(() => {
+    setFilterProps(getUniqueDataForEachPartition);
+  }, [messages, partitions]);
+
+  const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const query = event.target.value;
+
+    setSearchQuery(query);
+    debouncedCallback({ q: query });
+  };
+
+  const handleDateTimeChange = () => {
+    if (searchTimestamp !== prevSearchTimestamp) {
+      if (searchTimestamp) {
+        const timestamp: number = searchTimestamp.getTime();
+
+        setSearchTimestamp(searchTimestamp);
+        setQueryParams({
+          ...queryParams,
+          seekType: SeekTypes.TIMESTAMP,
+          seekTo: selectedPartitions.map((p) => `${p.value}::${timestamp}`),
+        });
+      } else {
+        setSearchTimestamp(null);
+        const { seekTo, seekType, ...queryParamsWithoutSeek } = queryParams;
+        setQueryParams(queryParamsWithoutSeek);
+      }
+    }
+  };
+
+  const handleSeekTypeChange = (
+    event: React.ChangeEvent<HTMLSelectElement>
+  ) => {
+    setSelectedSeekType(event.target.value as SeekType);
+  };
+
+  const handleOffsetChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const offset = event.target.value || '0';
+    setSearchOffset(offset);
+    debouncedCallback({
+      seekType: SeekTypes.OFFSET,
+      seekTo: selectedPartitions.map((p) => `${p.value}::${offset}`),
+    });
+  };
 
-  const [searchText, setSearchText] = React.useState<string>('');
+  const handlePartitionsChange = (options: Option[]) => {
+    setSelectedPartitions(options);
 
-  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    setSearchText(event.target.value);
+    debouncedCallback({
+      seekType: options.length > 0 ? selectedSeekType : undefined,
+      seekTo:
+        options.length > 0
+          ? options.map((p) => `${p.value}::${getSeekToValuesForPartitions(p)}`)
+          : undefined,
+    });
   };
 
   const getTimestampDate = (timestamp: number) => {
     return format(new Date(timestamp * 1000), 'MM.dd.yyyy HH:mm:ss');
   };
 
-  const getMessageContentHeaders = () => {
+  const getMessageContentHeaders = React.useMemo(() => {
     const message = messages[0];
     const headers: JSX.Element[] = [];
-    const content = JSON.parse(message.content);
-    Object.keys(content).forEach((k) =>
-      headers.push(<th>{`content.${k}`}</th>)
-    );
-
+    try {
+      const content =
+        typeof message.content !== 'object'
+          ? JSON.parse(message.content)
+          : message.content;
+      Object.keys(content).forEach((k) =>
+        headers.push(<th key={Math.random()}>{`content.${k}`}</th>)
+      );
+    } catch (e) {
+      headers.push(<th>Content</th>);
+    }
     return headers;
-  };
+  }, [messages]);
 
-  const getMessageContentBody = (content: string) => {
-    const c = JSON.parse(content);
+  const getMessageContentBody = (content: any) => {
     const columns: JSX.Element[] = [];
-    Object.values(c).map((v) => columns.push(<td>{JSON.stringify(v)}</td>));
+    try {
+      const c = typeof content !== 'object' ? JSON.parse(content) : content;
+      Object.values(c).map((v) =>
+        columns.push(<td key={Math.random()}>{JSON.stringify(v)}</td>)
+      );
+    } catch (e) {
+      columns.push(<td>{content}</td>);
+    }
     return columns;
   };
 
+  const onNext = (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    const seekTo: string[] = filterProps
+      .filter(
+        (value) =>
+          selectedPartitions.findIndex((p) => p.value === value.partition) > -1
+      )
+      .map((p) => `${p.partition}::${p.offset}`);
+
+    setQueryParams({
+      ...queryParams,
+      seekType: SeekTypes.OFFSET,
+      seekTo,
+    });
+  };
+
+  const filterOptions = (options: Option[], filter: any) => {
+    if (!filter) {
+      return options;
+    }
+    return options.filter(
+      ({ value }) => value.toString() && value.toString() === filter
+    );
+  };
+
+  const getTopicMessagesTable = () => {
+    return messages.length > 0 ? (
+      <div>
+        <table className="table is-striped is-fullwidth">
+          <thead>
+            <tr>
+              <th>Timestamp</th>
+              <th>Offset</th>
+              <th>Partition</th>
+              {getMessageContentHeaders}
+            </tr>
+          </thead>
+          <tbody>
+            {messages.map((message) => (
+              <tr key={`${message.timestamp}${Math.random()}`}>
+                <td>{getTimestampDate(message.timestamp)}</td>
+                <td>{message.offset}</td>
+                <td>{message.partition}</td>
+                {getMessageContentBody(message.content)}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+        <div className="columns">
+          <div className="column is-full">
+            <CustomParamButton
+              className="is-link is-pulled-right"
+              type={CustomParamButtonType.chevronRight}
+              onClick={onNext}
+              btnText="Next"
+            />
+          </div>
+        </div>
+      </div>
+    ) : (
+      <div>No messages at selected topic</div>
+    );
+  };
+
+  if (!isFetched) {
+    return <PageLoader isFullHeight={false} />;
+  }
+
   return (
-    // eslint-disable-next-line no-nested-ternary
-    isFetched ? (
-      messages.length > 0 ? (
-        <div>
-          <div className="columns">
-            <div className="column is-half is-offset-half">
+    <div>
+      <div className="columns">
+        <div className="column is-one-fifth">
+          <label className="label">Partitions</label>
+          <MultiSelect
+            options={partitions.map((p) => ({
+              label: `Partition #${p.partition.toString()}`,
+              value: p.partition,
+            }))}
+            filterOptions={filterOptions}
+            value={selectedPartitions}
+            onChange={handlePartitionsChange}
+            labelledBy="Select partitions"
+          />
+        </div>
+        <div className="column is-one-fifth">
+          <label className="label">Seek Type</label>
+          <div className="select is-block">
+            <select
+              id="selectSeekType"
+              name="selectSeekType"
+              onChange={handleSeekTypeChange}
+              defaultValue={SeekTypes.OFFSET}
+              value={selectedSeekType}
+            >
+              <option value={SeekTypes.OFFSET}>Offset</option>
+              <option value={SeekTypes.TIMESTAMP}>Timestamp</option>
+            </select>
+          </div>
+        </div>
+        <div className="column is-one-fifth">
+          {selectedSeekType === SeekTypes.OFFSET ? (
+            <>
+              <label className="label">Offset</label>
               <input
-                id="searchText"
+                id="searchOffset"
+                name="searchOffset"
                 type="text"
-                name="searchText"
                 className="input"
-                placeholder="Search"
-                value={searchText}
-                onChange={handleInputChange}
+                value={searchOffset}
+                onChange={handleOffsetChange}
               />
-            </div>
-          </div>
-          <table className="table is-striped is-fullwidth">
-            <thead>
-              <tr>
-                <th>Timestamp</th>
-                <th>Offset</th>
-                <th>Partition</th>
-                {getMessageContentHeaders()}
-              </tr>
-            </thead>
-            <tbody>
-              {messages
-                .filter(
-                  (message) =>
-                    !searchText || message?.content?.indexOf(searchText) >= 0
-                )
-                .map((message) => (
-                  <tr key={message.timestamp}>
-                    <td>{getTimestampDate(message.timestamp)}</td>
-                    <td>{message.offset}</td>
-                    <td>{message.partition}</td>
-                    {getMessageContentBody(message.content)}
-                  </tr>
-                ))}
-            </tbody>
-          </table>
+            </>
+          ) : (
+            <>
+              <label className="label">Timestamp</label>
+              <DatePicker
+                selected={searchTimestamp}
+                onChange={(date) => setSearchTimestamp(date)}
+                onCalendarClose={handleDateTimeChange}
+                showTimeInput
+                timeInputLabel="Time:"
+                dateFormat="MMMM d, yyyy h:mm aa"
+                className="input"
+              />
+            </>
+          )}
         </div>
-      ) : (
-        <div>No messages at selected topic</div>
-      )
-    ) : (
-      <PageLoader isFullHeight={false} />
-    )
+        <div className="column is-two-fifths">
+          <label className="label">Search</label>
+          <input
+            id="searchText"
+            type="text"
+            name="searchText"
+            className="input"
+            placeholder="Search"
+            value={searchQuery}
+            onChange={handleQueryChange}
+          />
+        </div>
+      </div>
+      {getTopicMessagesTable()}
+    </div>
   );
 };
 

+ 13 - 3
kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesContainer.ts

@@ -1,9 +1,15 @@
 import { connect } from 'react-redux';
-import { ClusterName, RootState, TopicName } from 'redux/interfaces';
+import {
+  ClusterName,
+  RootState,
+  TopicMessageQueryParams,
+  TopicName,
+} from 'redux/interfaces';
 import { RouteComponentProps, withRouter } from 'react-router-dom';
 import { fetchTopicMessages } from 'redux/actions';
 import {
   getIsTopicMessagesFetched,
+  getPartitionsByTopicName,
   getTopicMessages,
 } from 'redux/reducers/topics/selectors';
 
@@ -28,11 +34,15 @@ const mapStateToProps = (
   topicName,
   isFetched: getIsTopicMessagesFetched(state),
   messages: getTopicMessages(state),
+  partitions: getPartitionsByTopicName(state, topicName),
 });
 
 const mapDispatchToProps = {
-  fetchTopicMessages: (clusterName: ClusterName, topicName: TopicName) =>
-    fetchTopicMessages(clusterName, topicName),
+  fetchTopicMessages: (
+    clusterName: ClusterName,
+    topicName: TopicName,
+    queryParams: Partial<TopicMessageQueryParams>
+  ) => fetchTopicMessages(clusterName, topicName, queryParams),
 };
 
 export default withRouter(

+ 1 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamButton.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 export enum CustomParamButtonType {
   plus = 'fa-plus',
   minus = 'fa-minus',
+  chevronRight = 'fa-chevron-right',
 }
 
 interface Props {

+ 0 - 4
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx

@@ -1,11 +1,7 @@
 import React from 'react';
-import { useFormContext, ErrorMessage } from 'react-hook-form';
-import { TopicFormCustomParam } from 'redux/interfaces';
 import CustomParamSelect from 'components/Topics/shared/Form/CustomParams/CustomParamSelect';
 import CustomParamValue from 'components/Topics/shared/Form/CustomParams/CustomParamValue';
 import CustomParamAction from 'components/Topics/shared/Form/CustomParams/CustomParamAction';
-import { INDEX_PREFIX } from './CustomParams';
-import CustomParamOptions from './CustomParamOptions';
 
 interface Props {
   isDisabled: boolean;

+ 0 - 1
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamValue.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 import { useFormContext, ErrorMessage } from 'react-hook-form';
-import { camelCase } from 'lodash';
 import CUSTOM_PARAMS_OPTIONS from './customParamsOptions';
 
 interface Props {

+ 8 - 2
kafka-ui-react-app/src/redux/actions/thunks.ts

@@ -7,6 +7,7 @@ import {
   TopicFormData,
   TopicName,
   Topic,
+  TopicMessageQueryParams,
 } from 'redux/interfaces';
 
 import * as actions from './actions';
@@ -59,11 +60,16 @@ export const fetchTopicList = (
 
 export const fetchTopicMessages = (
   clusterName: ClusterName,
-  topicName: TopicName
+  topicName: TopicName,
+  queryParams: Partial<TopicMessageQueryParams>
 ): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchTopicMessagesAction.request());
   try {
-    const messages = await api.getTopicMessages(clusterName, topicName);
+    const messages = await api.getTopicMessages(
+      clusterName,
+      topicName,
+      queryParams
+    );
     dispatch(actions.fetchTopicMessagesAction.success(messages));
   } catch (e) {
     dispatch(actions.fetchTopicMessagesAction.failure());

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

@@ -9,6 +9,7 @@ import {
   TopicFormCustomParam,
   TopicFormFormattedParams,
   TopicFormCustomParams,
+  TopicMessageQueryParams,
 } from 'redux/interfaces';
 import { BASE_URL, BASE_PARAMS } from 'lib/constants';
 
@@ -49,11 +50,30 @@ export const getTopics = (clusterName: ClusterName): Promise<Topic[]> =>
 
 export const getTopicMessages = (
   clusterName: ClusterName,
-  topicName: TopicName
-): Promise<TopicMessage[]> =>
-  fetch(`${BASE_URL}/clusters/${clusterName}/topics/${topicName}/messages`, {
-    ...BASE_PARAMS,
-  }).then((res) => res.json());
+  topicName: TopicName,
+  queryParams: Partial<TopicMessageQueryParams>
+): Promise<TopicMessage[]> => {
+  let searchParams = '';
+  Object.entries({ ...queryParams }).forEach((entry) => {
+    const key = entry[0];
+    const value = entry[1];
+    if (value) {
+      if (Array.isArray(value)) {
+        value.forEach((v) => {
+          searchParams += `${key}=${v}&`;
+        });
+      } else {
+        searchParams += `${key}=${value}&`;
+      }
+    }
+  });
+  return fetch(
+    `${BASE_URL}/clusters/${clusterName}/topics/${topicName}/messages?${searchParams}`,
+    {
+      ...BASE_PARAMS,
+    }
+  ).then((res) => res.json());
+};
 
 export const postTopic = (
   clusterName: ClusterName,

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

@@ -57,7 +57,21 @@ export interface TopicMessage {
   timestampType: string;
   key: string;
   headers: Record<string, string>;
-  content: string;
+  content: any;
+}
+
+export enum SeekTypes {
+  OFFSET = 'OFFSET',
+  TIMESTAMP = 'TIMESTAMP',
+}
+
+export type SeekType = keyof typeof SeekTypes;
+
+export interface TopicMessageQueryParams {
+  q: string;
+  limit: number;
+  seekType: SeekType;
+  seekTo: string[];
 }
 
 export interface TopicFormCustomParam {

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

@@ -80,6 +80,12 @@ export const getTopicByName = createSelector(
   (topics, topicName) => topics[topicName]
 );
 
+export const getPartitionsByTopicName = createSelector(
+  getTopicMap,
+  getTopicName,
+  (topics, topicName) => topics[topicName].partitions
+);
+
 export const getFullTopic = createSelector(getTopicByName, (topic) =>
   topic && topic.config && !!topic.partitionCount ? topic : undefined
 );