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

This commit is contained in:
Anna Antipova 2021-07-20 17:41:28 +03:00
commit d47c7b1649
42 changed files with 742 additions and 191 deletions

View file

@ -10,6 +10,6 @@ jobs:
- uses: kentaro-m/task-completed-checker-action@v0.1.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
- uses: derkinderfietsen/pr-description-enforcer@v1
- uses: dekinderfiets/pr-description-enforcer@v0.0.1
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
repo-token: '${{ secrets.GITHUB_TOKEN }}'

View file

@ -109,6 +109,7 @@ To read more please follow to [chart documentation](charts/kafka-ui/README.md)
# Guides
- [SSO configuration](guides/SSO.md)
- [AWS IAM configuration](guides/AWS_IAM.md)
## Connecting to a Secure Broker

View file

@ -0,0 +1,5 @@
SchemaRegistryProps {
org.eclipse.jetty.jaas.spi.PropertyFileLoginModule required
file="/conf/schema_registry.password"
debug="false";
};

View file

@ -0,0 +1 @@
admin: OBF:1w8t1tvf1w261w8v1w1c1tvn1w8x,admin

View file

@ -0,0 +1,66 @@
---
version: '2'
services:
zookeeper1:
image: confluentinc/cp-zookeeper:5.2.4
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- 2182:2181
kafka1:
image: confluentinc/cp-kafka:5.2.4
depends_on:
- zookeeper1
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=localhost -Dcom.sun.management.jmxremote.rmi.port=9998
ports:
- 9093:9093
- 9998:9998
schemaregistry1:
image: confluentinc/cp-schema-registry:5.5.0
ports:
- 18085:8085
depends_on:
- zookeeper1
- kafka1
volumes:
- ./jaas:/conf
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
# Default credentials: admin/letmein
SCHEMA_REGISTRY_AUTHENTICATION_METHOD: BASIC
SCHEMA_REGISTRY_AUTHENTICATION_REALM: SchemaRegistryProps
SCHEMA_REGISTRY_AUTHENTICATION_ROLES: admin
SCHEMA_REGISTRY_OPTS: -Djava.security.auth.login.config=/conf/schema_registry.jaas
SCHEMA_REGISTRY_SCHEMA_REGISTRY_INTER_INSTANCE_PROTOCOL: "http"
SCHEMA_REGISTRY_LOG4J_ROOT_LOGLEVEL: INFO
SCHEMA_REGISTRY_KAFKASTORE_TOPIC: _schemas
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-console-producer --broker-list kafka1:29092 -topic second.users < /data/message.json'"

View file

@ -26,7 +26,7 @@ services:
KAFKA_CLUSTERS_1_BOOTSTRAPSERVERS: kafka1:29092
KAFKA_CLUSTERS_1_ZOOKEEPER: zookeeper1:2181
KAFKA_CLUSTERS_1_JMXPORT: 9998
KAFKA_CLUSTERS_1_SCHEMAREGISTRY: http://schemaregistry0:8085
KAFKA_CLUSTERS_1_SCHEMAREGISTRY: http://schemaregistry1:8085
KAFKA_CLUSTERS_1_KAFKACONNECT_0_NAME: first
KAFKA_CLUSTERS_1_KAFKACONNECT_0_ADDRESS: http://kafka-connect0:8083

1
docker/message.json Normal file
View file

@ -0,0 +1 @@
{}

41
guides/AWS_IAM.md Normal file
View file

@ -0,0 +1,41 @@
# How to configure AWS IAM Authentication
UI for Apache Kafka comes with built-in [aws-msk-iam-auth](https://github.com/aws/aws-msk-iam-auth) library.
You could pass sasl configs in properties section for each cluster.
More details could be found here: [aws-msk-iam-auth](https://github.com/aws/aws-msk-iam-auth)
## Examples:
Please replace
* <KAFKA_URL> with broker list
* <PROFILE_NAME> with your aws profile
### Running From Docker Image
```sh
docker run -p 8080:8080 \
-e KAFKA_CLUSTERS_0_NAME=local \
-e KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=<KAFKA_URL> \
-e KAFKA_CLUSTERS_0_PROPERTIES_SECURITY_PROTOCOL=SASL_SSL \
-e KAFKA_CLUSTERS_0_PROPERTIES_SASL_MECHANISM=AWS_MSK_IAM \
-e KAFKA_CLUSTERS_0_PROPERTIES_SASL_CLIENT_CALLBACK_HANDLER_CLASS=software.amazon.msk.auth.iam.IAMClientCallbackHandler \
-e KAFKA_CLUSTERS_0_PROPERTIES_SASL_JAAS_CONFIG=software.amazon.msk.auth.iam.IAMLoginModule required awsProfileName="<PROFILE_NAME>"; \
-d provectuslabs/kafka-ui:latest
```
### Configuring by application.yaml
```yaml
kafka:
clusters:
- name: local
bootstrapServers: <KAFKA_URL>
properties:
security.protocol: SASL_SSL
sasl.mechanism: AWS_MSK_IAM
sasl.client.callback.handler.class: software.amazon.msk.auth.iam.IAMClientCallbackHandler
sasl.jaas.config: software.amazon.msk.auth.iam.IAMLoginModule required awsProfileName="<PROFILE_NAME>";
```

View file

@ -97,6 +97,12 @@
<version>${confluent.version}</version>
</dependency>
<dependency>
<groupId>software.amazon.msk</groupId>
<artifactId>aws-msk-iam-auth</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro</artifactId>

View file

@ -0,0 +1,13 @@
package com.provectus.kafka.ui.exception;
public class TopicMetadataException extends CustomBaseException {
public TopicMetadataException(String message) {
super(message);
}
@Override
public ErrorCode getErrorCode() {
return ErrorCode.INVALID_ENTITY_STATE;
}
}

View file

@ -0,0 +1,29 @@
package com.provectus.kafka.ui.model;
import com.provectus.kafka.ui.exception.IllegalEntityStateException;
import java.util.Arrays;
public enum CleanupPolicy {
DELETE("delete"),
COMPACT("compact"),
COMPACT_DELETE("compact, delete"),
UNKNOWN("unknown");
private final String cleanUpPolicy;
CleanupPolicy(String cleanUpPolicy) {
this.cleanUpPolicy = cleanUpPolicy;
}
public String getCleanUpPolicy() {
return cleanUpPolicy;
}
public static CleanupPolicy fromString(String string) {
return Arrays.stream(CleanupPolicy.values())
.filter(v -> v.cleanUpPolicy.equals(string))
.findFirst()
.orElseThrow(() ->
new IllegalEntityStateException("Unknown cleanup policy value: " + string));
}
}

View file

@ -27,4 +27,5 @@ public class InternalClusterMetrics {
private final Map<Integer, InternalBrokerMetrics> internalBrokerMetrics;
private final List<Metric> metrics;
private final int zooKeeperStatus;
private final String version;
}

View file

@ -14,6 +14,7 @@ public class InternalTopic {
private final Map<Integer, InternalPartition> partitions;
private final List<InternalTopicConfig> topicConfigs;
private final CleanupPolicy cleanUpPolicy;
private final int replicas;
private final int partitionCount;
private final int inSyncReplicas;

View file

@ -1,6 +1,8 @@
package com.provectus.kafka.ui.service;
import com.provectus.kafka.ui.exception.TopicMetadataException;
import com.provectus.kafka.ui.exception.ValidationException;
import com.provectus.kafka.ui.model.CleanupPolicy;
import com.provectus.kafka.ui.model.CreateTopicMessage;
import com.provectus.kafka.ui.model.ExtendedAdminClient;
import com.provectus.kafka.ui.model.InternalBrokerDiskUsage;
@ -159,6 +161,7 @@ public class KafkaService {
.onlinePartitionCount(topicsMetrics.getOnlinePartitionCount())
.offlinePartitionCount(topicsMetrics.getOfflinePartitionCount())
.zooKeeperStatus(ClusterUtil.convertToIntServerStatus(zookeeperStatus))
.version(version)
.build();
return currentCluster.toBuilder()
@ -205,12 +208,18 @@ public class KafkaService {
private Map<String, InternalTopic> mergeWithConfigs(
List<InternalTopic> topics, Map<String, List<InternalTopicConfig>> configs) {
return topics.stream().map(
t -> t.toBuilder().topicConfigs(configs.get(t.getName())).build()
).collect(Collectors.toMap(
InternalTopic::getName,
e -> e
));
return topics.stream()
.map(t -> t.toBuilder().topicConfigs(configs.get(t.getName())).build())
.map(t -> t.toBuilder().cleanUpPolicy(
CleanupPolicy.fromString(t.getTopicConfigs().stream()
.filter(config -> config.getName().equals("cleanup.policy"))
.findFirst()
.orElseGet(() -> InternalTopicConfig.builder().value("unknown").build())
.getValue())).build())
.collect(Collectors.toMap(
InternalTopic::getName,
e -> e
));
}
@SneakyThrows
@ -223,11 +232,12 @@ public class KafkaService {
final Mono<Map<String, List<InternalTopicConfig>>> configsMono =
loadTopicsConfig(adminClient, topics);
return ClusterUtil.toMono(adminClient.describeTopics(topics).all()).map(
m -> m.values().stream().map(ClusterUtil::mapToInternalTopic).collect(Collectors.toList())
).flatMap(internalTopics -> configsMono.map(configs ->
mergeWithConfigs(internalTopics, configs).values()
)).flatMapMany(Flux::fromIterable);
return ClusterUtil.toMono(adminClient.describeTopics(topics).all())
.map(m -> m.values().stream()
.map(ClusterUtil::mapToInternalTopic).collect(Collectors.toList()))
.flatMap(internalTopics -> configsMono
.map(configs -> mergeWithConfigs(internalTopics, configs).values()))
.flatMapMany(Flux::fromIterable);
}
@ -260,10 +270,12 @@ public class KafkaService {
topicData.getReplicationFactor().shortValue());
newTopic.configs(topicData.getConfigs());
return createTopic(adminClient, newTopic).map(v -> topicData);
}).flatMap(
topicData ->
getTopicsData(adminClient, Collections.singleton(topicData.getName()))
.next()
})
.onErrorResume(t -> Mono.error(new TopicMetadataException(t.getMessage())))
.flatMap(
topicData ->
getTopicsData(adminClient, Collections.singleton(topicData.getName()))
.next()
).switchIfEmpty(Mono.error(new RuntimeException("Can't find created topic")))
.flatMap(t ->
loadTopicsConfig(adminClient, Collections.singletonList(t.getName()))

View file

@ -0,0 +1,60 @@
package com.provectus.kafka.ui;
import com.provectus.kafka.ui.model.TopicCreation;
import java.util.UUID;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.reactive.server.WebTestClient;
@ContextConfiguration(initializers = {AbstractBaseTest.Initializer.class})
@Log4j2
@AutoConfigureWebTestClient(timeout = "10000")
public class KafkaTopicCreateTests extends AbstractBaseTest {
@Autowired
private WebTestClient webTestClient;
private TopicCreation topicCreation;
@BeforeEach
public void setUpBefore() {
this.topicCreation = new TopicCreation()
.replicationFactor(1)
.partitions(3)
.name(UUID.randomUUID().toString());
}
@Test
void shouldCreateNewTopicSuccessfully() {
webTestClient.post()
.uri("/api/clusters/{clusterName}/topics", LOCAL)
.bodyValue(topicCreation)
.exchange()
.expectStatus()
.isOk();
}
@Test
void shouldReturn400IfTopicAlreadyExists() {
TopicCreation topicCreation = new TopicCreation()
.replicationFactor(1)
.partitions(3)
.name(UUID.randomUUID().toString());
webTestClient.post()
.uri("/api/clusters/{clusterName}/topics", LOCAL)
.bodyValue(topicCreation)
.exchange()
.expectStatus()
.isOk();
webTestClient.post()
.uri("/api/clusters/{clusterName}/topics", LOCAL)
.bodyValue(topicCreation)
.exchange()
.expectStatus()
.isBadRequest();
}
}

View file

@ -1417,6 +1417,8 @@ components:
type: array
items:
$ref: '#/components/schemas/BrokerDiskUsage'
version:
type: string
BrokerDiskUsage:
type: object
@ -1484,6 +1486,13 @@ components:
type: integer
underReplicatedPartitions:
type: integer
cleanUpPolicy:
type: string
enum:
- DELETE
- COMPACT
- COMPACT_DELETE
- UNKNOWN
partitions:
type: array
items:

View file

@ -15,7 +15,7 @@
<junit.version>5.7.0</junit.version>
<aspectj.version>1.9.6</aspectj.version>
<allure.version>2.13.7</allure.version>
<json-smart.version>1.1.1</json-smart.version>
<json-smart.version>1.3.2</json-smart.version>
<testcontainers.version>1.15.2</testcontainers.version>
<selenide.version>5.16.2</selenide.version>
<assertj.version>3.17.1</assertj.version>
@ -155,40 +155,71 @@
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven.surefire-plugin.version}</version>
<configuration>
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
</configuration>
<dependencies>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>${aspectj.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-maven</artifactId>
<version>${allure-maven.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>local</id>
<!-- Disabling e2e tests by default (for local dev envs) since complex setup is needed for UI tests -->
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven.surefire-plugin.version}</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>prod</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven.surefire-plugin.version}</version>
<configuration>
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
</configuration>
<dependencies>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>${aspectj.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-maven</artifactId>
<version>${allure-maven.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View file

@ -27,6 +27,7 @@ const Brokers: React.FC<Props> = ({
diskUsage,
fetchClusterStats,
fetchBrokers,
version,
}) => {
const { clusterName } = useParams<{ clusterName: ClusterName }>();
@ -56,6 +57,9 @@ const Brokers: React.FC<Props> = ({
{zkOnline ? 'Online' : 'Offline'}
</span>
</Indicator>
<Indicator className="is-one-third" label="Version">
{version}
</Indicator>
</MetricsWrapper>
<MetricsWrapper title="Partitions">
<Indicator label="Online">

View file

@ -12,6 +12,7 @@ import {
getOutOfSyncReplicasCount,
getUnderReplicatedPartitionCount,
getDiskUsage,
getVersion,
} from 'redux/reducers/brokers/selectors';
import Brokers from 'components/Brokers/Brokers';
@ -26,6 +27,7 @@ const mapStateToProps = (state: RootState) => ({
outOfSyncReplicasCount: getOutOfSyncReplicasCount(state),
underReplicatedPartitionCount: getUnderReplicatedPartitionCount(state),
diskUsage: getDiskUsage(state),
version: getVersion(state),
});
const mapDispatchToProps = {

View file

@ -26,6 +26,7 @@ describe('Brokers Component', () => {
inSyncReplicasCount={0}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
fetchClusterStats={jest.fn()}
fetchBrokers={jest.fn()}
diskUsage={undefined}
@ -61,6 +62,7 @@ describe('Brokers Component', () => {
inSyncReplicasCount={64}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
fetchClusterStats={jest.fn()}
fetchBrokers={jest.fn()}
diskUsage={[

View file

@ -69,6 +69,7 @@ exports[`Brokers Component Brokers Empty matches Brokers Empty snapshot 1`] = `
onlinePartitionCount={0}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
zooKeeperStatus={0}
>
<div
@ -179,6 +180,29 @@ exports[`Brokers Component Brokers Empty matches Brokers Empty snapshot 1`] = `
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Version"
>
<div
className="level-item is-one-third"
>
<div
title="Version"
>
<p
className="heading"
>
Version
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>
@ -400,6 +424,7 @@ exports[`Brokers Component Brokers matches snapshot 1`] = `
onlinePartitionCount={64}
outOfSyncReplicasCount={0}
underReplicatedPartitionCount={0}
version="1"
zooKeeperStatus={1}
>
<div
@ -510,6 +535,29 @@ exports[`Brokers Component Brokers matches snapshot 1`] = `
</div>
</div>
</Indicator>
<Indicator
className="is-one-third"
label="Version"
>
<div
className="level-item is-one-third"
>
<div
title="Version"
>
<p
className="heading"
>
Version
</p>
<p
className="title has-text-centered"
>
1
</p>
</div>
</div>
</Indicator>
</div>
</div>
</MetricsWrapper>

View file

@ -16,7 +16,7 @@ import ListItem from './ListItem';
export interface Props extends ConsumerGroup, ConsumerGroupDetails {
clusterName: ClusterName;
consumers?: ConsumerGroupTopicPartition[];
partitions?: ConsumerGroupTopicPartition[];
isFetched: boolean;
isDeleted: boolean;
fetchConsumerGroupDetails: (
@ -29,7 +29,7 @@ export interface Props extends ConsumerGroup, ConsumerGroupDetails {
const Details: React.FC<Props> = ({
clusterName,
groupId,
consumers,
partitions,
isFetched,
isDeleted,
fetchConsumerGroupDetails,
@ -38,7 +38,7 @@ const Details: React.FC<Props> = ({
React.useEffect(() => {
fetchConsumerGroupDetails(clusterName, groupId);
}, [fetchConsumerGroupDetails, clusterName, groupId]);
const items = consumers || [];
const items = partitions || [];
const [isConfirmationModelVisible, setIsConfirmationModelVisible] =
React.useState<boolean>(false);
const history = useHistory();
@ -96,6 +96,11 @@ const Details: React.FC<Props> = ({
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr>
<td colSpan={10}>No active consumer groups</td>
</tr>
)}
{items.map((consumer) => (
<ListItem
key={consumer.consumerId}

View file

@ -20,7 +20,7 @@ describe('Details component', () => {
isDeleted={false}
fetchConsumerGroupDetails={jest.fn()}
deleteConsumerGroup={jest.fn()}
consumers={[
partitions={[
{
consumerId:
'consumer-messages-consumer-1-122fbf98-643b-491d-8aec-c0641d2513d0',

View file

@ -0,0 +1,8 @@
import React from 'react';
import { containerRendersView } from 'lib/testHelpers';
import Details from 'components/ConsumerGroups/Details/Details';
import DetailsContainer from 'components/ConsumerGroups/Details/DetailsContainer';
describe('DetailsContainer', () => {
containerRendersView(<DetailsContainer />, Details);
});

View file

@ -22,51 +22,52 @@ const List: React.FC<Props> = ({ consumerGroups }) => {
<Breadcrumb>All Consumer Groups</Breadcrumb>
<div className="box">
{consumerGroups.length > 0 ? (
<div>
<div className="columns">
<div className="column is-half is-offset-half">
<input
id="searchText"
type="text"
name="searchText"
className="input"
placeholder="Search"
value={searchText}
onChange={handleInputChange}
/>
</div>
<div>
<div className="columns">
<div className="column is-half is-offset-half">
<input
id="searchText"
type="text"
name="searchText"
className="input"
placeholder="Search"
value={searchText}
onChange={handleInputChange}
/>
</div>
<table className="table is-striped is-fullwidth is-hoverable">
<thead>
<tr>
<th>Consumer group ID</th>
<th>Num of members</th>
<th>Num of topics</th>
<th>Messages behind</th>
<th>Coordinator</th>
<th>State</th>
</tr>
</thead>
<tbody>
{consumerGroups
.filter(
(consumerGroup) =>
!searchText ||
consumerGroup?.groupId?.indexOf(searchText) >= 0
)
.map((consumerGroup) => (
<ListItem
key={consumerGroup.groupId}
consumerGroup={consumerGroup}
/>
))}
</tbody>
</table>
</div>
) : (
'No active consumer groups'
)}
<table className="table is-striped is-fullwidth is-hoverable">
<thead>
<tr>
<th>Consumer group ID</th>
<th>Num of members</th>
<th>Num of topics</th>
<th>Messages behind</th>
<th>Coordinator</th>
<th>State</th>
</tr>
</thead>
<tbody>
{consumerGroups
.filter(
(consumerGroup) =>
!searchText ||
consumerGroup?.groupId?.indexOf(searchText) >= 0
)
.map((consumerGroup) => (
<ListItem
key={consumerGroup.groupId}
consumerGroup={consumerGroup}
/>
))}
{consumerGroups.length === 0 && (
<tr>
<td colSpan={10}>No active consumer groups</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);

View file

@ -0,0 +1,46 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import List from 'components/ConsumerGroups/List/List';
describe('List', () => {
const mockConsumerGroups = [
{
groupId: 'groupId',
members: 0,
topics: 1,
simple: false,
partitionAssignor: '',
coordinator: {
id: 1,
host: 'host',
},
partitions: [
{
consumerId: null,
currentOffset: 0,
endOffset: 0,
host: null,
messagesBehind: 0,
partition: 1,
topic: 'topic',
},
],
},
];
const component = shallow(
<List consumerGroups={mockConsumerGroups} clusterName="cluster" />
);
const componentEmpty = mount(
<List consumerGroups={[]} clusterName="cluster" />
);
it('render empty List consumer Groups', () => {
expect(componentEmpty.find('td').text()).toEqual(
'No active consumer groups'
);
});
it('render List consumer Groups', () => {
expect(component.exists('.section')).toBeTruthy();
});
});

View file

@ -0,0 +1,8 @@
import React from 'react';
import { containerRendersView } from 'lib/testHelpers';
import ListContainer from 'components/ConsumerGroups/List/ListContainer';
import List from 'components/ConsumerGroups/List/List';
describe('ListContainer', () => {
containerRendersView(<ListContainer />, List);
});

View file

@ -0,0 +1,33 @@
import React from 'react';
import { shallow } from 'enzyme';
import ListItem from 'components/ConsumerGroups/List/ListItem';
describe('List', () => {
const mockConsumerGroup = {
groupId: 'groupId',
members: 0,
topics: 1,
simple: false,
partitionAssignor: '',
coordinator: {
id: 1,
host: 'host',
},
partitions: [
{
consumerId: null,
currentOffset: 0,
endOffset: 0,
host: null,
messagesBehind: 0,
partition: 1,
topic: 'topic',
},
],
};
const component = shallow(<ListItem consumerGroup={mockConsumerGroup} />);
it('render empty ListItem', () => {
expect(component.exists('.is-clickable')).toBeTruthy();
});
});

View file

@ -18,6 +18,7 @@ const ClusterWidget: React.FC<ClusterWidgetProps> = ({
bytesOutPerSec,
onlinePartitionCount,
readOnly,
version,
},
}) => (
<div className="column is-full-modile is-6">
@ -38,6 +39,10 @@ const ClusterWidget: React.FC<ClusterWidgetProps> = ({
<table className="table is-fullwidth">
<tbody>
<tr>
<th>Version</th>
<td>{version}</td>
</tr>
<tr>
<th>Brokers</th>
<td>

View file

@ -21,6 +21,12 @@ exports[`ClusterWidget when cluster is offline matches snapshot 1`] = `
className="table is-fullwidth"
>
<tbody>
<tr>
<th>
Version
</th>
<td />
</tr>
<tr>
<th>
Brokers
@ -100,6 +106,12 @@ exports[`ClusterWidget when cluster is online matches snapshot 1`] = `
className="table is-fullwidth"
>
<tbody>
<tr>
<th>
Version
</th>
<td />
</tr>
<tr>
<th>
Brokers

View file

@ -124,6 +124,11 @@ const Details: React.FC<DetailsProps> = ({
{versions.map((version) => (
<SchemaVersion key={version.id} version={version} />
))}
{versions.length === 0 && (
<tr>
<td colSpan={10}>No active Schema</td>
</tr>
)}
</tbody>
</table>
</div>

View file

@ -49,6 +49,13 @@ describe('Details', () => {
{...props}
/>
);
describe('empty table', () => {
it('render empty table', () => {
const component = shallow(setupWrapper());
expect(component.find('td').text()).toEqual('No active Schema');
});
});
describe('Initial state', () => {
it('should call fetchSchemaVersions every render', () => {
mount(

View file

@ -110,7 +110,15 @@ exports[`Details View Initial state matches snapshot 1`] = `
</th>
</tr>
</thead>
<tbody />
<tbody>
<tr>
<td
colSpan={10}
>
No active Schema
</td>
</tr>
</tbody>
</table>
</div>
</div>
@ -393,7 +401,15 @@ exports[`Details View when page with schema versions loaded when versions are em
</th>
</tr>
</thead>
<tbody />
<tbody>
<tr>
<td
colSpan={10}
>
No active Schema
</td>
</tr>
</tbody>
</table>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { Topic, TopicDetails, ConsumerGroup } from 'generated-sources';
import { ClusterName, TopicName } from 'redux/interfaces';
import ConsumerGroupStateTag from 'components/common/ConsumerGroupState/ConsumerGroupStateTag';
import { useHistory } from 'react-router';
import { clusterConsumerGroupsPath } from 'lib/paths';
interface Props extends Topic, TopicDetails {
clusterName: ClusterName;
@ -26,43 +27,46 @@ const TopicConsumerGroups: React.FC<Props> = ({
const history = useHistory();
function goToConsumerGroupDetails(consumer: ConsumerGroup) {
history.push(`consumer-groups/${consumer.groupId}`);
history.push(
`${clusterConsumerGroupsPath(clusterName)}/${consumer.groupId}`
);
}
return (
<div className="box">
{consumerGroups.length > 0 ? (
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Consumer group ID</th>
<th>Num of members</th>
<th>Messages behind</th>
<th>Coordinator</th>
<th>State</th>
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Consumer group ID</th>
<th>Num of members</th>
<th>Messages behind</th>
<th>Coordinator</th>
<th>State</th>
</tr>
</thead>
<tbody>
{consumerGroups.map((consumer) => (
<tr
key={consumer.groupId}
className="is-clickable"
onClick={() => goToConsumerGroupDetails(consumer)}
>
<td>{consumer.groupId}</td>
<td>{consumer.members}</td>
<td>{consumer.messagesBehind}</td>
<td>{consumer.coordinator?.id}</td>
<td>
<ConsumerGroupStateTag state={consumer.state} />
</td>
</tr>
</thead>
<tbody>
{consumerGroups.map((consumer) => (
<tr
key={consumer.groupId}
className="is-clickable"
onClick={() => goToConsumerGroupDetails(consumer)}
>
<td>{consumer.groupId}</td>
<td>{consumer.members}</td>
<td>{consumer.messagesBehind}</td>
<td>{consumer.coordinator?.id}</td>
<td>
<ConsumerGroupStateTag state={consumer.state} />
</td>
</tr>
))}
</tbody>
</table>
) : (
'No active consumer groups'
)}
))}
{consumerGroups.length === 0 && (
<tr>
<td colSpan={10}>No active consumer groups</td>
</tr>
)}
</tbody>
</table>
</div>
);
};

View file

@ -40,8 +40,7 @@ describe('Details', () => {
topicName={mockTopicName}
/>
);
expect(component.exists('.table')).toBeFalsy();
expect(component.find('td').text()).toEqual('No active consumer groups');
});
it('render ConsumerGroups in Topic', () => {
@ -54,7 +53,6 @@ describe('Details', () => {
topicName={mockTopicName}
/>
);
expect(component.exists('.table')).toBeTruthy();
expect(component.exists('tbody')).toBeTruthy();
});
});

View file

@ -9,51 +9,50 @@ export interface MessagesTableProp {
onNext(event: React.MouseEvent<HTMLButtonElement>): void;
}
const MessagesTable: React.FC<MessagesTableProp> = ({ messages, onNext }) => {
if (!messages.length) {
return <div>No messages at selected topic</div>;
}
return (
<>
<table className="table is-fullwidth is-narrow">
<thead>
const MessagesTable: React.FC<MessagesTableProp> = ({ messages, onNext }) => (
<>
<table className="table is-fullwidth is-narrow">
<thead>
<tr>
<th>Timestamp</th>
<th>Key</th>
<th>Offset</th>
<th>Partition</th>
<th>Content</th>
<th> </th>
</tr>
</thead>
<tbody>
{messages.map(
({ partition, offset, timestamp, content, key }: TopicMessage) => (
<MessageItem
key={`message-${timestamp.getTime()}-${offset}`}
partition={partition}
offset={offset}
timestamp={timestamp}
content={content}
messageKey={key}
/>
)
)}
{messages.length === 0 && (
<tr>
<th>Timestamp</th>
<th>Key</th>
<th>Offset</th>
<th>Partition</th>
<th>Content</th>
<th> </th>
<td colSpan={10}>No messages at selected topic</td>
</tr>
</thead>
<tbody>
{messages.map(
({ partition, offset, timestamp, content, key }: TopicMessage) => (
<MessageItem
key={`message-${timestamp.getTime()}-${offset}`}
partition={partition}
offset={offset}
timestamp={timestamp}
content={content}
messageKey={key}
/>
)
)}
</tbody>
</table>
<div className="columns">
<div className="column is-full">
<CustomParamButton
className="is-link is-pulled-right"
type="fa-chevron-right"
onClick={onNext}
btnText="Next"
/>
</div>
)}
</tbody>
</table>
<div className="columns">
<div className="column is-full">
<CustomParamButton
className="is-link is-pulled-right"
type="fa-chevron-right"
onClick={onNext}
btnText="Next"
/>
</div>
</>
);
};
</div>
</>
);
export default MessagesTable;

View file

@ -18,9 +18,9 @@ describe('MessagesTable', () => {
describe('when topic is empty', () => {
it('renders table row with JSONEditor', () => {
const wrapper = shallow(setupWrapper());
expect(wrapper.exists('table')).toBeFalsy();
expect(wrapper.exists('CustomParamButton')).toBeFalsy();
expect(wrapper.text()).toEqual('No messages at selected topic');
expect(wrapper.find('td').text()).toEqual(
'No messages at selected topic'
);
});
it('matches snapshot', () => {

View file

@ -63,7 +63,55 @@ exports[`MessagesTable when topic contains messages matches snapshot 1`] = `
`;
exports[`MessagesTable when topic is empty matches snapshot 1`] = `
<div>
No messages at selected topic
</div>
<Fragment>
<table
className="table is-fullwidth is-narrow"
>
<thead>
<tr>
<th>
Timestamp
</th>
<th>
Key
</th>
<th>
Offset
</th>
<th>
Partition
</th>
<th>
Content
</th>
<th>
</th>
</tr>
</thead>
<tbody>
<tr>
<td
colSpan={10}
>
No messages at selected topic
</td>
</tr>
</tbody>
</table>
<div
className="columns"
>
<div
className="column is-full"
>
<CustomParamButton
btnText="Next"
className="is-link is-pulled-right"
onClick={[MockFunction]}
type="fa-chevron-right"
/>
</div>
</div>
</Fragment>
`;

View file

@ -102,6 +102,11 @@ const Overview: React.FC<Props> = ({
</td>
</tr>
))}
{partitions?.length === 0 && (
<tr>
<td colSpan={10}>No Partitions found</td>
</tr>
)}
</tbody>
</table>
</div>

View file

@ -38,5 +38,20 @@ describe('Overview', () => {
expect(component.exists('Dropdown')).toBeTruthy();
});
it('does not render Partitions', () => {
const componentEmpty = shallow(
<Overview
name={mockTopicName}
partitions={[]}
internal={mockInternal}
clusterName={mockClusterName}
topicName={mockTopicName}
clearTopicMessages={mockClearTopicMessages}
/>
);
expect(componentEmpty.find('td').text()).toEqual('No Partitions found');
});
});
});

View file

@ -332,9 +332,7 @@ export const fetchTopicConsumerGroups =
...state.byName,
[topicName]: {
...state.byName[topicName],
consumerGroups: {
...consumerGroups,
},
consumerGroups,
},
},
};

View file

@ -48,3 +48,8 @@ export const getDiskUsage = createSelector(
brokersState,
({ diskUsage }) => diskUsage
);
export const getVersion = createSelector(
brokersState,
({ version }) => version
);