diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000..4ec791ebb9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,92 @@ +name: "\U0001F41E Bug report" +description: File a bug report +labels: ["status/triage", "type/bug"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Hi, thanks for raising the issue(-s), all contributions really matter! + Please, note that we'll close the issue without further explanation if you don't follow + this template and don't provide the information requested within this template. + + - type: checkboxes + id: terms + attributes: + label: Issue submitter TODO list + description: By you checking these checkboxes we can be sure you've done the essential things. + options: + - label: I've looked up my issue in [FAQ](https://docs.kafka-ui.provectus.io/faq/common-problems) + required: true + - label: I've searched for an already existing issues [here](https://github.com/provectus/kafka-ui/issues) + required: true + - label: I've tried running `master`-labeled docker image and the issue still persists there + required: true + - label: I'm running a supported version of the application which is listed [here](https://github.com/provectus/kafka-ui/blob/master/SECURITY.md) + required: true + + - type: textarea + attributes: + label: Describe the bug (actual behavior) + description: A clear and concise description of what the bug is. Use a list, if there is more than one problem + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen + validations: + required: false + + - type: textarea + attributes: + label: Your installation details + description: | + How do you run the app? Please provide as much info as possible: + 1. App version (commit hash in the top left corner of the UI) + 2. Helm chart version, if you use one + 3. Your application config. Please remove the sensitive info like passwords or API keys. + 4. Any IAAC configs + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: | + Please write down the order of the actions required to reproduce the issue. + For the advanced setups/complicated issue, we might need you to provide + a minimal [reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). + validations: + required: true + + - type: textarea + attributes: + label: Screenshots + description: | + If applicable, add screenshots to help explain your problem + validations: + required: false + + - type: textarea + attributes: + label: Logs + description: | + If applicable, *upload* screenshots to help explain your problem + validations: + required: false + + - type: textarea + attributes: + label: Additional context + description: | + Add any other context about the problem here. E.G.: + 1. Are there any alternative scenarios (different data/methods/configuration/setup) you have tried? + Were they successful or the same issue occurred? Please provide steps as well. + 2. Related issues (if there are any). + 3. Logs (if available) + 4. Is there any serious impact or behaviour on the end-user because of this issue, that can be overlooked? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index b6bbcda11a..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: "\U0001F41E Bug report" -about: Create a bug report -title: '' -labels: status/triage, type/bug -assignees: '' - ---- - - - - - -**Describe the bug** (Actual behavior) - - -**Expected behavior** - - -**Set up** - - - -**Steps to Reproduce** - - -1. - -**Screenshots** - - - -**Additional context** - diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000000..e52c2b7ae9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,66 @@ +name: "\U0001F680 Feature request" +description: Propose a new feature +labels: ["status/triage", "type/feature"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Hi, thanks for raising the issue(-s), all contributions really matter! + Please, note that we'll close the issue without further explanation if you don't follow + this template and don't provide the information requested within this template. + + - type: checkboxes + id: terms + attributes: + label: Issue submitter TODO list + description: By you checking these checkboxes we can be sure you've done the essential things. + options: + - label: I've searched for an already existing issues [here](https://github.com/provectus/kafka-ui/issues) + required: true + - label: I'm running a supported version of the application which is listed [here](https://github.com/provectus/kafka-ui/blob/master/SECURITY.md) and the feature is not present there + required: true + + - type: textarea + attributes: + label: Is your proposal related to a problem? + description: | + Provide a clear and concise description of what the problem is. + For example, "I'm always frustrated when..." + validations: + required: false + + - type: textarea + attributes: + label: Describe the feature you're interested in + description: | + Provide a clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + attributes: + label: Describe alternatives you've considered + description: | + Let us know about other solutions you've tried or researched. + validations: + required: false + + - type: input + attributes: + label: Version you're running + description: | + Please provide the app version you're currently running: + 1. App version (commit hash in the top left corner of the UI) + validations: + required: true + + - type: textarea + attributes: + label: Additional context + description: | + Is there anything else you can add about the proposal? + You might want to link to related issues here, if you haven't already. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 49a07ae978..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Propose a new feature -title: '' -labels: status/triage, type/feature -assignees: '' - ---- - - - -### Which version of the app are you running? - - -### Is your proposal related to a problem? - - - -### Describe the solution you'd like - - - -### Describe alternatives you've considered - - - -### Additional context - - - diff --git a/.github/ISSUE_TEMPLATE/helm.yml b/.github/ISSUE_TEMPLATE/helm.yml new file mode 100644 index 0000000000..b36733504e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/helm.yml @@ -0,0 +1,92 @@ +name: "⎈ K8s/Helm problem report" +description: "Report a problem with k8s/helm charts/etc" +labels: ["status/triage", "scope/k8s"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Hi, thanks for raising the issue(-s), all contributions really matter! + Please, note that we'll close the issue without further explanation if you don't follow + this template and don't provide the information requested within this template. + + - type: checkboxes + id: terms + attributes: + label: Issue submitter TODO list + description: By you checking these checkboxes we can be sure you've done the essential things. + options: + - label: I've looked up my issue in [FAQ](https://docs.kafka-ui.provectus.io/faq/common-problems) + required: true + - label: I've searched for an already existing issues [here](https://github.com/provectus/kafka-ui/issues) + required: true + - label: I've tried running `master`-labeled docker image and the issue still persists there + required: true + - label: I'm running a supported version of the application which is listed [here](https://github.com/provectus/kafka-ui/blob/master/SECURITY.md) + required: true + + - type: textarea + attributes: + label: Describe the bug (actual behavior) + description: A clear and concise description of what the bug is. Use a list, if there is more than one problem + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen + validations: + required: false + + - type: textarea + attributes: + label: Your installation details + description: | + How do you run the app? Please provide as much info as possible: + 1. App version (commit hash in the top left corner of the UI) + 2. Helm chart version + 3. Your application config. Please remove the sensitive info like passwords or API keys. + 4. Any IAAC configs + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: | + Please write down the order of the actions required to reproduce the issue. + For the advanced setups/complicated issue, we might need you to provide + a minimal [reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). + validations: + required: true + + - type: textarea + attributes: + label: Screenshots + description: | + If applicable, add screenshots to help explain your problem + validations: + required: false + + - type: textarea + attributes: + label: Logs + description: | + If applicable, *upload* screenshots to help explain your problem + validations: + required: false + + - type: textarea + attributes: + label: Additional context + description: | + Add any other context about the problem here. E.G.: + 1. Are there any alternative scenarios (different data/methods/configuration/setup) you have tried? + Were they successful or the same issue occurred? Please provide steps as well. + 2. Related issues (if there are any). + 3. Logs (if available) + 4. Is there any serious impact or behaviour on the end-user because of this issue, that can be overlooked? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/k8s.md b/.github/ISSUE_TEMPLATE/k8s.md deleted file mode 100644 index 5f4eb8ca75..0000000000 --- a/.github/ISSUE_TEMPLATE/k8s.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: "⎈ K8s/Helm problem report" -about: Report a problem with k8s/helm charts/etc -title: '' -labels: scope/k8s, status/triage -assignees: azatsafin - ---- - - - -**Describe the bug** - - - -**Set up** - - - -**Steps to Reproduce** -Steps to reproduce the behavior: - -1. - -**Expected behavior** - - -**Screenshots** - - - -**Additional context** - diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 75ea103c31..7e8552962a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,8 +8,6 @@ updates: timezone: Europe/Moscow reviewers: - "Haarolean" - assignees: - - "Haarolean" labels: - "scope/backend" - "type/dependencies" @@ -99,8 +97,6 @@ updates: timezone: Europe/Moscow reviewers: - "Haarolean" - assignees: - - "Haarolean" labels: - "scope/infrastructure" - "type/dependencies" diff --git a/kafka-ui-api/pom.xml b/kafka-ui-api/pom.xml index e61827fd73..7f2d4c16be 100644 --- a/kafka-ui-api/pom.xml +++ b/kafka-ui-api/pom.xml @@ -21,6 +21,12 @@ + + + org.springframework + spring-core + 6.0.8 + org.springframework.boot spring-boot-starter-webflux @@ -109,6 +115,12 @@ io.projectreactor.addons reactor-extra + + + org.json + json + ${org.json.version} + org.springframework.boot diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java index 1d5cc5393c..64ec894cd5 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/ClustersProperties.java @@ -131,8 +131,9 @@ public class ClustersProperties { @Data public static class Masking { Type type; - List fields; //if null or empty list - policy will be applied to all fields - List pattern; //used when type=MASK + List fields; + String fieldsNamePattern; + List maskingCharsReplacement; //used when type=MASK String replacement; //used when type=REPLACE String topicKeysPattern; String topicValuesPattern; diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapProperties.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapProperties.java new file mode 100644 index 0000000000..13119b3bb9 --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapProperties.java @@ -0,0 +1,26 @@ +package com.provectus.kafka.ui.config.auth; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("spring.ldap") +@Data +public class LdapProperties { + + private String urls; + private String base; + private String adminUser; + private String adminPassword; + private String userFilterSearchBase; + private String userFilterSearchFilter; + + @Value("${oauth2.ldap.activeDirectory:false}") + private boolean isActiveDirectory; + @Value("${oauth2.ldap.aсtiveDirectory.domain:@null}") + private String activeDirectoryDomain; + + @Value("${oauth2.ldap.groupRoleAttribute:cn}") + private String groupRoleAttribute; + +} diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapSecurityConfig.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapSecurityConfig.java index 0ba5c231f4..fae1125239 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapSecurityConfig.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/LdapSecurityConfig.java @@ -1,13 +1,23 @@ package com.provectus.kafka.ui.config.auth; +import static com.provectus.kafka.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST; + +import com.provectus.kafka.ui.service.rbac.AccessControlService; +import com.provectus.kafka.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor; +import java.util.Collection; import java.util.List; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.ldap.core.DirContextOperations; import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.ldap.core.support.LdapContextSource; import org.springframework.security.authentication.AuthenticationManager; @@ -16,70 +26,71 @@ import org.springframework.security.authentication.ReactiveAuthenticationManager import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider; import org.springframework.security.ldap.authentication.BindAuthenticator; import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider; import org.springframework.security.ldap.search.FilterBasedLdapUserSearch; import org.springframework.security.ldap.search.LdapUserSearch; +import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper; import org.springframework.security.web.server.SecurityWebFilterChain; @Configuration @EnableWebFluxSecurity @ConditionalOnProperty(value = "auth.type", havingValue = "LDAP") @Import(LdapAutoConfiguration.class) +@EnableConfigurationProperties(LdapProperties.class) +@RequiredArgsConstructor @Slf4j -public class LdapSecurityConfig extends AbstractAuthSecurityConfig { +public class LdapSecurityConfig { - @Value("${spring.ldap.urls}") - private String ldapUrls; - @Value("${spring.ldap.dn.pattern:#{null}}") - private String ldapUserDnPattern; - @Value("${spring.ldap.adminUser:#{null}}") - private String adminUser; - @Value("${spring.ldap.adminPassword:#{null}}") - private String adminPassword; - @Value("${spring.ldap.userFilter.searchBase:#{null}}") - private String userFilterSearchBase; - @Value("${spring.ldap.userFilter.searchFilter:#{null}}") - private String userFilterSearchFilter; - - @Value("${oauth2.ldap.activeDirectory:false}") - private boolean isActiveDirectory; - @Value("${oauth2.ldap.aсtiveDirectory.domain:#{null}}") - private String activeDirectoryDomain; + private final LdapProperties props; @Bean - public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) { + public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource, + ApplicationContext context, + @Nullable AccessControlService acs) { + var rbacEnabled = acs != null && acs.isRbacEnabled(); BindAuthenticator ba = new BindAuthenticator(contextSource); - if (ldapUserDnPattern != null) { - ba.setUserDnPatterns(new String[] {ldapUserDnPattern}); + if (props.getBase() != null) { + ba.setUserDnPatterns(new String[] {props.getBase()}); } - if (userFilterSearchFilter != null) { + if (props.getUserFilterSearchFilter() != null) { LdapUserSearch userSearch = - new FilterBasedLdapUserSearch(userFilterSearchBase, userFilterSearchFilter, contextSource); + new FilterBasedLdapUserSearch(props.getUserFilterSearchBase(), props.getUserFilterSearchFilter(), + contextSource); ba.setUserSearch(userSearch); } AbstractLdapAuthenticationProvider authenticationProvider; - if (!isActiveDirectory) { - authenticationProvider = new LdapAuthenticationProvider(ba); + if (!props.isActiveDirectory()) { + authenticationProvider = rbacEnabled + ? new LdapAuthenticationProvider(ba, new RbacLdapAuthoritiesExtractor(context)) + : new LdapAuthenticationProvider(ba); } else { - authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(activeDirectoryDomain, ldapUrls); + authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(), + props.getUrls()); // TODO Issue #3741 authenticationProvider.setUseAuthenticationRequestCredentials(true); } + if (rbacEnabled) { + authenticationProvider.setUserDetailsContextMapper(new UserDetailsMapper()); + } + AuthenticationManager am = new ProviderManager(List.of(authenticationProvider)); return new ReactiveAuthenticationManagerAdapter(am); } @Bean + @Primary public BaseLdapPathContextSource contextSource() { LdapContextSource ctx = new LdapContextSource(); - ctx.setUrl(ldapUrls); - ctx.setUserDn(adminUser); - ctx.setPassword(adminPassword); + ctx.setUrl(props.getUrls()); + ctx.setUserDn(props.getAdminUser()); + ctx.setPassword(props.getAdminPassword()); ctx.afterPropertiesSet(); return ctx; } @@ -87,20 +98,35 @@ public class LdapSecurityConfig extends AbstractAuthSecurityConfig { @Bean public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) { log.info("Configuring LDAP authentication."); - if (isActiveDirectory) { + if (props.isActiveDirectory()) { log.info("Active Directory support for LDAP has been enabled."); } - http + return http .authorizeExchange() .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() - .and() - .httpBasic(); - return http.csrf().disable().build(); + .and() + .formLogin() + + .and() + .logout() + + .and() + .csrf().disable() + .build(); + } + + private static class UserDetailsMapper extends LdapUserDetailsMapper { + @Override + public UserDetails mapUserFromContext(DirContextOperations ctx, String username, + Collection authorities) { + UserDetails userDetails = super.mapUserFromContext(ctx, username, authorities); + return new RbacLdapUser(userDetails); + } } } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/OAuthSecurityConfig.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/OAuthSecurityConfig.java index 1d237e0173..5db612f256 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/OAuthSecurityConfig.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/OAuthSecurityConfig.java @@ -115,7 +115,7 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { @Nullable private ProviderAuthorityExtractor getExtractor(final String providerId, AccessControlService acs) { final String provider = getProviderByProviderId(providerId); - Optional extractor = acs.getExtractors() + Optional extractor = acs.getOauthExtractors() .stream() .filter(e -> e.isApplicable(provider)) .findFirst(); diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/RbacLdapUser.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/RbacLdapUser.java new file mode 100644 index 0000000000..037d2fd302 --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/RbacLdapUser.java @@ -0,0 +1,60 @@ +package com.provectus.kafka.ui.config.auth; + +import java.util.Collection; +import java.util.stream.Collectors; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class RbacLdapUser implements UserDetails, RbacUser { + + private final UserDetails userDetails; + + public RbacLdapUser(UserDetails userDetails) { + this.userDetails = userDetails; + } + + @Override + public String name() { + return userDetails.getUsername(); + } + + @Override + public Collection groups() { + return userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); + } + + @Override + public Collection getAuthorities() { + return userDetails.getAuthorities(); + } + + @Override + public String getPassword() { + return userDetails.getPassword(); + } + + @Override + public String getUsername() { + return userDetails.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return userDetails.isAccountNonExpired(); + } + + @Override + public boolean isAccountNonLocked() { + return userDetails.isAccountNonLocked(); + } + + @Override + public boolean isCredentialsNonExpired() { + return userDetails.isCredentialsNonExpired(); + } + + @Override + public boolean isEnabled() { + return userDetails.isEnabled(); + } +} diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/condition/ActiveDirectoryCondition.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/condition/ActiveDirectoryCondition.java new file mode 100644 index 0000000000..c38e83238a --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/condition/ActiveDirectoryCondition.java @@ -0,0 +1,21 @@ +package com.provectus.kafka.ui.config.auth.condition; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +public class ActiveDirectoryCondition extends AllNestedConditions { + + public ActiveDirectoryCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnProperty(value = "auth.type", havingValue = "LDAP") + public static class OnAuthType { + + } + + @ConditionalOnProperty(value = "${oauth2.ldap.activeDirectory}:false", havingValue = "true", matchIfMissing = false) + public static class OnActiveDirectory { + + } +} diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalBroker.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalBroker.java index edab9a8aeb..4a0d1ba0dd 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalBroker.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/InternalBroker.java @@ -1,6 +1,7 @@ package com.provectus.kafka.ui.model; import java.math.BigDecimal; +import javax.annotation.Nullable; import lombok.Data; import org.apache.kafka.common.Node; @@ -10,15 +11,27 @@ public class InternalBroker { private final Integer id; private final String host; private final Integer port; - private final BigDecimal bytesInPerSec; - private final BigDecimal bytesOutPerSec; + private final @Nullable BigDecimal bytesInPerSec; + private final @Nullable BigDecimal bytesOutPerSec; + private final @Nullable Integer partitionsLeader; + private final @Nullable Integer partitions; + private final @Nullable Integer inSyncPartitions; + private final @Nullable BigDecimal leadersSkew; + private final @Nullable BigDecimal partitionsSkew; - public InternalBroker(Node node, Statistics statistics) { + public InternalBroker(Node node, + PartitionDistributionStats partitionDistribution, + Statistics statistics) { this.id = node.id(); this.host = node.host(); this.port = node.port(); this.bytesInPerSec = statistics.getMetrics().getBrokerBytesInPerSec().get(node.id()); this.bytesOutPerSec = statistics.getMetrics().getBrokerBytesOutPerSec().get(node.id()); + this.partitionsLeader = partitionDistribution.getPartitionLeaders().get(node); + this.partitions = partitionDistribution.getPartitionsCount().get(node); + this.inSyncPartitions = partitionDistribution.getInSyncPartitions().get(node); + this.leadersSkew = partitionDistribution.leadersSkew(node); + this.partitionsSkew = partitionDistribution.partitionsSkew(node); } } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java new file mode 100644 index 0000000000..b625533d1d --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java @@ -0,0 +1,93 @@ +package com.provectus.kafka.ui.model; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.TopicPartitionInfo; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Slf4j +public class PartitionDistributionStats { + + // avg skew will show unuseful results on low number of partitions + private static final int MIN_PARTITIONS_FOR_SKEW_CALCULATION = 50; + + private static final MathContext ROUNDING_MATH_CTX = new MathContext(3); + + private final Map partitionLeaders; + private final Map partitionsCount; + private final Map inSyncPartitions; + private final double avgLeadersCntPerBroker; + private final double avgPartitionsPerBroker; + private final boolean skewCanBeCalculated; + + public static PartitionDistributionStats create(Statistics stats) { + return create(stats, MIN_PARTITIONS_FOR_SKEW_CALCULATION); + } + + static PartitionDistributionStats create(Statistics stats, int minPartitionsForSkewCalculation) { + var partitionLeaders = new HashMap(); + var partitionsReplicated = new HashMap(); + var isr = new HashMap(); + int partitionsCnt = 0; + for (TopicDescription td : stats.getTopicDescriptions().values()) { + for (TopicPartitionInfo tp : td.partitions()) { + partitionsCnt++; + tp.replicas().forEach(r -> incr(partitionsReplicated, r)); + tp.isr().forEach(r -> incr(isr, r)); + if (tp.leader() != null) { + incr(partitionLeaders, tp.leader()); + } + } + } + int nodesWithPartitions = partitionsReplicated.size(); + int partitionReplications = partitionsReplicated.values().stream().mapToInt(i -> i).sum(); + var avgPartitionsPerBroker = nodesWithPartitions == 0 ? 0 : ((double) partitionReplications) / nodesWithPartitions; + + int nodesWithLeaders = partitionLeaders.size(); + int leadersCnt = partitionLeaders.values().stream().mapToInt(i -> i).sum(); + var avgLeadersCntPerBroker = nodesWithLeaders == 0 ? 0 : ((double) leadersCnt) / nodesWithLeaders; + + return new PartitionDistributionStats( + partitionLeaders, + partitionsReplicated, + isr, + avgLeadersCntPerBroker, + avgPartitionsPerBroker, + partitionsCnt >= minPartitionsForSkewCalculation + ); + } + + private static void incr(Map map, Node n) { + map.compute(n, (k, c) -> c == null ? 1 : ++c); + } + + @Nullable + public BigDecimal partitionsSkew(Node node) { + return calculateAvgSkew(partitionsCount.get(node), avgPartitionsPerBroker); + } + + @Nullable + public BigDecimal leadersSkew(Node node) { + return calculateAvgSkew(partitionLeaders.get(node), avgLeadersCntPerBroker); + } + + // Returns difference (in percents) from average value, null if it can't be calculated + @Nullable + private BigDecimal calculateAvgSkew(@Nullable Integer value, double avgValue) { + if (avgValue == 0 || !skewCanBeCalculated) { + return null; + } + value = value == null ? 0 : value; + return new BigDecimal((value - avgValue) / avgValue * 100.0).round(ROUNDING_MATH_CTX); + } +} diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/ConsumerRecordDeserializer.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/ConsumerRecordDeserializer.java index 8c7a3024ed..f5b7018034 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/ConsumerRecordDeserializer.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/ConsumerRecordDeserializer.java @@ -123,11 +123,11 @@ public class ConsumerRecordDeserializer { } private static Long getKeySize(ConsumerRecord consumerRecord) { - return consumerRecord.key() != null ? (long) consumerRecord.key().get().length : null; + return consumerRecord.key() != null ? (long) consumerRecord.serializedKeySize() : null; } private static Long getValueSize(ConsumerRecord consumerRecord) { - return consumerRecord.value() != null ? (long) consumerRecord.value().get().length : null; + return consumerRecord.value() != null ? (long) consumerRecord.serializedValueSize() : null; } private static int headerSize(Header header) { diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/SerdesInitializer.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/SerdesInitializer.java index 40ea320b2e..66692894a6 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/SerdesInitializer.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/serdes/SerdesInitializer.java @@ -122,8 +122,6 @@ public class SerdesInitializer { registeredSerdes, Optional.ofNullable(clusterProperties.getDefaultKeySerde()) .map(name -> Preconditions.checkNotNull(registeredSerdes.get(name), "Default key serde not found")) - .or(() -> Optional.ofNullable(registeredSerdes.get(SchemaRegistrySerde.name()))) - .or(() -> Optional.ofNullable(registeredSerdes.get(ProtobufFileSerde.name()))) .orElse(null), Optional.ofNullable(clusterProperties.getDefaultValueSerde()) .map(name -> Preconditions.checkNotNull(registeredSerdes.get(name), "Default value serde not found")) diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java index 720642157b..8a2ac1a63e 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/BrokerService.java @@ -10,6 +10,7 @@ import com.provectus.kafka.ui.model.BrokersLogdirsDTO; import com.provectus.kafka.ui.model.InternalBroker; import com.provectus.kafka.ui.model.InternalBrokerConfig; import com.provectus.kafka.ui.model.KafkaCluster; +import com.provectus.kafka.ui.model.PartitionDistributionStats; import com.provectus.kafka.ui.service.metrics.RawMetric; import java.util.Collections; import java.util.HashMap; @@ -64,11 +65,13 @@ public class BrokerService { } public Flux getBrokers(KafkaCluster cluster) { + var stats = statisticsCache.get(cluster); + var partitionsDistribution = PartitionDistributionStats.create(stats); return adminClientService .get(cluster) .flatMap(ReactiveAdminClient::describeCluster) .map(description -> description.getNodes().stream() - .map(node -> new InternalBroker(node, statisticsCache.get(cluster))) + .map(node -> new InternalBroker(node, partitionsDistribution, stats)) .collect(Collectors.toList())) .flatMapMany(Flux::fromIterable); } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaConnectService.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaConnectService.java index d07ef7ed2d..98b61541c5 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaConnectService.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaConnectService.java @@ -109,6 +109,7 @@ public class KafkaConnectService { private Stream getStringsForSearch(FullConnectorInfoDTO fullConnectorInfo) { return Stream.of( fullConnectorInfo.getName(), + fullConnectorInfo.getConnect(), fullConnectorInfo.getStatus().getState().getValue(), fullConnectorInfo.getType().getValue()); } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/analyze/TopicAnalysisStats.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/analyze/TopicAnalysisStats.java index d5b4400807..f36d3bec4d 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/analyze/TopicAnalysisStats.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/analyze/TopicAnalysisStats.java @@ -43,8 +43,7 @@ class TopicAnalysisStats { Long max; final UpdateDoublesSketch sizeSketch = DoublesSketch.builder().build(); - void apply(byte[] bytes) { - int len = bytes.length; + void apply(int len) { sum += len; min = minNullable(min, len); max = maxNullable(max, len); @@ -98,7 +97,7 @@ class TopicAnalysisStats { if (rec.key() != null) { byte[] keyBytes = rec.key().get(); - keysSize.apply(keyBytes); + keysSize.apply(rec.serializedKeySize()); uniqKeys.update(keyBytes); } else { nullKeys++; @@ -106,7 +105,7 @@ class TopicAnalysisStats { if (rec.value() != null) { byte[] valueBytes = rec.value().get(); - valuesSize.apply(valueBytes); + valuesSize.apply(rec.serializedValueSize()); uniqValues.update(valueBytes); } else { nullValues++; diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/DataMasking.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/DataMasking.java index 78e74f3332..ad1c2da31e 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/DataMasking.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/DataMasking.java @@ -44,7 +44,7 @@ public class DataMasking { public static DataMasking create(@Nullable List config) { return new DataMasking( Optional.ofNullable(config).orElse(List.of()).stream().map(property -> { - Preconditions.checkNotNull(property.getType(), "masking type not specifed"); + Preconditions.checkNotNull(property.getType(), "masking type not specified"); Preconditions.checkArgument( StringUtils.isNotEmpty(property.getTopicKeysPattern()) || StringUtils.isNotEmpty(property.getTopicValuesPattern()), diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java new file mode 100644 index 0000000000..9956394398 --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelector.java @@ -0,0 +1,28 @@ +package com.provectus.kafka.ui.service.masking.policies; + +import com.provectus.kafka.ui.config.ClustersProperties; +import com.provectus.kafka.ui.exception.ValidationException; +import java.util.regex.Pattern; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +interface FieldsSelector { + + static FieldsSelector create(ClustersProperties.Masking property) { + if (StringUtils.hasText(property.getFieldsNamePattern()) && !CollectionUtils.isEmpty(property.getFields())) { + throw new ValidationException("You can't provide both fieldNames & fieldsNamePattern for masking"); + } + if (StringUtils.hasText(property.getFieldsNamePattern())) { + Pattern pattern = Pattern.compile(property.getFieldsNamePattern()); + return f -> pattern.matcher(f).matches(); + } + if (!CollectionUtils.isEmpty(property.getFields())) { + return f -> property.getFields().contains(f); + } + //no pattern, no field names - mean all fields should be masked + return fieldName -> true; + } + + boolean shouldBeMasked(String fieldName); + +} diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java index dbbc5d131a..e6a469f2c0 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Mask.java @@ -15,8 +15,8 @@ class Mask extends MaskingPolicy { private final UnaryOperator masker; - Mask(List fieldNames, List maskingChars) { - super(fieldNames); + Mask(FieldsSelector fieldsSelector, List maskingChars) { + super(fieldsSelector); this.masker = createMasker(maskingChars); } @@ -38,22 +38,13 @@ class Mask extends MaskingPolicy { for (int i = 0; i < input.length(); i++) { int cp = input.codePointAt(i); switch (Character.getType(cp)) { - case Character.SPACE_SEPARATOR: - case Character.LINE_SEPARATOR: - case Character.PARAGRAPH_SEPARATOR: - sb.appendCodePoint(cp); // keeping separators as-is - break; - case Character.UPPERCASE_LETTER: - sb.append(maskingChars.get(0)); - break; - case Character.LOWERCASE_LETTER: - sb.append(maskingChars.get(1)); - break; - case Character.DECIMAL_DIGIT_NUMBER: - sb.append(maskingChars.get(2)); - break; - default: - sb.append(maskingChars.get(3)); + case Character.SPACE_SEPARATOR, + Character.LINE_SEPARATOR, + Character.PARAGRAPH_SEPARATOR -> sb.appendCodePoint(cp); // keeping separators as-is + case Character.UPPERCASE_LETTER -> sb.append(maskingChars.get(0)); + case Character.LOWERCASE_LETTER -> sb.append(maskingChars.get(1)); + case Character.DECIMAL_DIGIT_NUMBER -> sb.append(maskingChars.get(2)); + default -> sb.append(maskingChars.get(3)); } } return sb.toString(); diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java index 7a75338210..9b80da0cb1 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/MaskingPolicy.java @@ -2,46 +2,36 @@ package com.provectus.kafka.ui.service.masking.policies; import com.fasterxml.jackson.databind.node.ContainerNode; import com.provectus.kafka.ui.config.ClustersProperties; -import java.util.List; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public abstract class MaskingPolicy { - public static MaskingPolicy create(ClustersProperties.Masking property) { - List fields = property.getFields() == null - ? List.of() // empty list means that policy will be applied to all fields - : property.getFields(); - switch (property.getType()) { - case REMOVE: - return new Remove(fields); - case REPLACE: - return new Replace( - fields, - property.getReplacement() == null - ? Replace.DEFAULT_REPLACEMENT - : property.getReplacement() - ); - case MASK: - return new Mask( - fields, - property.getPattern() == null - ? Mask.DEFAULT_PATTERN - : property.getPattern() - ); - default: - throw new IllegalStateException("Unknown policy type: " + property.getType()); - } + FieldsSelector fieldsSelector = FieldsSelector.create(property); + return switch (property.getType()) { + case REMOVE -> new Remove(fieldsSelector); + case REPLACE -> new Replace( + fieldsSelector, + property.getReplacement() == null + ? Replace.DEFAULT_REPLACEMENT + : property.getReplacement() + ); + case MASK -> new Mask( + fieldsSelector, + property.getMaskingCharsReplacement() == null + ? Mask.DEFAULT_PATTERN + : property.getMaskingCharsReplacement() + ); + }; } //---------------------------------------------------------------- - // empty list means policy will be applied to all fields - private final List fieldNames; + private final FieldsSelector fieldsSelector; protected boolean fieldShouldBeMasked(String fieldName) { - return fieldNames.isEmpty() || fieldNames.contains(fieldName); + return fieldsSelector.shouldBeMasked(fieldName); } public abstract ContainerNode applyToJsonContainer(ContainerNode node); diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java index eb38b0d3e3..cc5cdd1415 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Remove.java @@ -4,12 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ContainerNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import java.util.List; + class Remove extends MaskingPolicy { - Remove(List fieldNames) { - super(fieldNames); + Remove(FieldsSelector fieldsSelector) { + super(fieldsSelector); } @Override diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java index 3af645cb11..1cf91793d2 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/masking/policies/Replace.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.node.ContainerNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.base.Preconditions; -import java.util.List; class Replace extends MaskingPolicy { @@ -14,8 +13,8 @@ class Replace extends MaskingPolicy { private final String replacement; - Replace(List fieldNames, String replacementString) { - super(fieldNames); + Replace(FieldsSelector fieldsSelector, String replacementString) { + super(fieldsSelector); this.replacement = Preconditions.checkNotNull(replacementString); } diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java index 3178feae34..e964f64a9b 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java @@ -12,6 +12,7 @@ import com.provectus.kafka.ui.model.rbac.AccessContext; import com.provectus.kafka.ui.model.rbac.Permission; import com.provectus.kafka.ui.model.rbac.Resource; import com.provectus.kafka.ui.model.rbac.Role; +import com.provectus.kafka.ui.model.rbac.Subject; import com.provectus.kafka.ui.model.rbac.permission.ConnectAction; import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction; import com.provectus.kafka.ui.model.rbac.permission.SchemaAction; @@ -19,11 +20,11 @@ import com.provectus.kafka.ui.model.rbac.permission.TopicAction; import com.provectus.kafka.ui.service.rbac.extractor.CognitoAuthorityExtractor; import com.provectus.kafka.ui.service.rbac.extractor.GithubAuthorityExtractor; import com.provectus.kafka.ui.service.rbac.extractor.GoogleAuthorityExtractor; -import com.provectus.kafka.ui.service.rbac.extractor.LdapAuthorityExtractor; import com.provectus.kafka.ui.service.rbac.extractor.ProviderAuthorityExtractor; import jakarta.annotation.PostConstruct; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -34,6 +35,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.core.env.Environment; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; @@ -50,10 +52,11 @@ public class AccessControlService { @Nullable private final InMemoryReactiveClientRegistrationRepository clientRegistrationRepository; + private final RoleBasedAccessControlProperties properties; + private final Environment environment; private boolean rbacEnabled = false; - private Set extractors = Collections.emptySet(); - private final RoleBasedAccessControlProperties properties; + private Set oauthExtractors = Collections.emptySet(); @PostConstruct public void init() { @@ -63,21 +66,26 @@ public class AccessControlService { } rbacEnabled = true; - this.extractors = properties.getRoles() + this.oauthExtractors = properties.getRoles() .stream() .map(role -> role.getSubjects() .stream() - .map(provider -> switch (provider.getProvider()) { + .map(Subject::getProvider) + .distinct() + .map(provider -> switch (provider) { case OAUTH_COGNITO -> new CognitoAuthorityExtractor(); case OAUTH_GOOGLE -> new GoogleAuthorityExtractor(); case OAUTH_GITHUB -> new GithubAuthorityExtractor(); - case LDAP, LDAP_AD -> new LdapAuthorityExtractor(); - }).collect(Collectors.toSet())) + default -> null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet())) .flatMap(Set::stream) .collect(Collectors.toSet()); - if ((clientRegistrationRepository == null || !clientRegistrationRepository.iterator().hasNext()) - && !properties.getRoles().isEmpty()) { + if (!properties.getRoles().isEmpty() + && "oauth2".equalsIgnoreCase(environment.getProperty("auth.type")) + && (clientRegistrationRepository == null || !clientRegistrationRepository.iterator().hasNext())) { log.error("Roles are configured but no authentication methods are present. Authentication might fail."); } } @@ -354,8 +362,8 @@ public class AccessControlService { return isAccessible(Resource.KSQL, null, user, context, requiredActions); } - public Set getExtractors() { - return extractors; + public Set getOauthExtractors() { + return oauthExtractors; } public List getRoles() { diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/LdapAuthorityExtractor.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/LdapAuthorityExtractor.java deleted file mode 100644 index 6284bb2923..0000000000 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/LdapAuthorityExtractor.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.provectus.kafka.ui.service.rbac.extractor; - -import com.provectus.kafka.ui.service.rbac.AccessControlService; -import java.util.Collections; -import java.util.Map; -import java.util.Set; -import lombok.extern.slf4j.Slf4j; -import reactor.core.publisher.Mono; - -@Slf4j -public class LdapAuthorityExtractor implements ProviderAuthorityExtractor { - - @Override - public boolean isApplicable(String provider) { - return false; // TODO #2752 - } - - @Override - public Mono> extract(AccessControlService acs, Object value, Map additionalParams) { - return Mono.just(Collections.emptySet()); // TODO #2752 - } - -} diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java new file mode 100644 index 0000000000..e24fc0aeda --- /dev/null +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java @@ -0,0 +1,70 @@ +package com.provectus.kafka.ui.service.rbac.extractor; + +import com.provectus.kafka.ui.config.auth.LdapProperties; +import com.provectus.kafka.ui.model.rbac.Role; +import com.provectus.kafka.ui.model.rbac.provider.Provider; +import com.provectus.kafka.ui.service.rbac.AccessControlService; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.ldap.core.support.BaseLdapPathContextSource; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator; +import org.springframework.util.Assert; + +@Slf4j +public class RbacLdapAuthoritiesExtractor extends DefaultLdapAuthoritiesPopulator { + + private final AccessControlService acs; + private final LdapProperties props; + + private final Function>, GrantedAuthority> authorityMapper = (record) -> { + String role = record.get(getGroupRoleAttribute()).get(0); + return new SimpleGrantedAuthority(role); + }; + + public RbacLdapAuthoritiesExtractor(ApplicationContext context) { + super(context.getBean(BaseLdapPathContextSource.class), null); + this.acs = context.getBean(AccessControlService.class); + this.props = context.getBean(LdapProperties.class); + } + + @Override + public Set getAdditionalRoles(DirContextOperations user, String username) { + return acs.getRoles() + .stream() + .map(Role::getSubjects) + .flatMap(List::stream) + .filter(s -> s.getProvider().equals(Provider.LDAP)) + .filter(s -> s.getType().equals("group")) + .flatMap(subject -> getRoles(subject.getValue(), user.getNameInNamespace(), username).stream()) + .collect(Collectors.toSet()); + } + + private Set getRoles(String groupSearchBase, String userDn, String username) { + Assert.notNull(groupSearchBase, "groupSearchBase is empty"); + + log.trace( + "Searching for roles for user [{}] with DN [{}], groupRoleAttribute [{}] and filter [{}] in search base [{}]", + username, userDn, props.getGroupRoleAttribute(), getGroupSearchFilter(), groupSearchBase); + + var ldapTemplate = getLdapTemplate(); + ldapTemplate.setIgnoreNameNotFoundException(true); + + Set>> userRoles = ldapTemplate.searchForMultipleAttributeValues( + groupSearchBase, getGroupSearchFilter(), new String[] {userDn, username}, + new String[] {props.getGroupRoleAttribute()}); + + return userRoles.stream() + .map(authorityMapper) + .peek(a -> log.debug("Mapped role [{}] for user [{}]", a, username)) + .collect(Collectors.toSet()); + } + +} diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/model/PartitionDistributionStatsTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/model/PartitionDistributionStatsTest.java new file mode 100644 index 0000000000..c83c4f5cd8 --- /dev/null +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/model/PartitionDistributionStatsTest.java @@ -0,0 +1,83 @@ +package com.provectus.kafka.ui.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.provectus.kafka.ui.service.ReactiveAdminClient; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.TopicPartitionInfo; +import org.assertj.core.data.Percentage; +import org.junit.jupiter.api.Test; + +class PartitionDistributionStatsTest { + + @Test + void skewCalculatedBasedOnPartitionsCounts() { + Node n1 = new Node(1, "n1", 9092); + Node n2 = new Node(2, "n2", 9092); + Node n3 = new Node(3, "n3", 9092); + Node n4 = new Node(4, "n4", 9092); + + var stats = PartitionDistributionStats.create( + Statistics.builder() + .clusterDescription( + new ReactiveAdminClient.ClusterDescription(null, "test", Set.of(n1, n2, n3), null)) + .topicDescriptions( + Map.of( + "t1", new TopicDescription( + "t1", false, + List.of( + new TopicPartitionInfo(0, n1, List.of(n1, n2), List.of(n1, n2)), + new TopicPartitionInfo(1, n2, List.of(n2, n3), List.of(n2, n3)) + ) + ), + "t2", new TopicDescription( + "t2", false, + List.of( + new TopicPartitionInfo(0, n1, List.of(n1, n2), List.of(n1, n2)), + new TopicPartitionInfo(1, null, List.of(n2, n1), List.of(n1)) + ) + ) + ) + ) + .build(), 4 + ); + + assertThat(stats.getPartitionLeaders()) + .containsExactlyInAnyOrderEntriesOf(Map.of(n1, 2, n2, 1)); + assertThat(stats.getPartitionsCount()) + .containsExactlyInAnyOrderEntriesOf(Map.of(n1, 3, n2, 4, n3, 1)); + assertThat(stats.getInSyncPartitions()) + .containsExactlyInAnyOrderEntriesOf(Map.of(n1, 3, n2, 3, n3, 1)); + + // Node(partitions): n1(3), n2(4), n3(1), n4(0) + // average partitions cnt = (3+4+1) / 3 = 2.666 (counting only nodes with partitions!) + assertThat(stats.getAvgPartitionsPerBroker()) + .isCloseTo(2.666, Percentage.withPercentage(1)); + + assertThat(stats.partitionsSkew(n1)) + .isCloseTo(BigDecimal.valueOf(12.5), Percentage.withPercentage(1)); + assertThat(stats.partitionsSkew(n2)) + .isCloseTo(BigDecimal.valueOf(50), Percentage.withPercentage(1)); + assertThat(stats.partitionsSkew(n3)) + .isCloseTo(BigDecimal.valueOf(-62.5), Percentage.withPercentage(1)); + assertThat(stats.partitionsSkew(n4)) + .isCloseTo(BigDecimal.valueOf(-100), Percentage.withPercentage(1)); + + // Node(leaders): n1(2), n2(1), n3(0), n4(0) + // average leaders cnt = (2+1) / 2 = 1.5 (counting only nodes with leaders!) + assertThat(stats.leadersSkew(n1)) + .isCloseTo(BigDecimal.valueOf(33.33), Percentage.withPercentage(1)); + assertThat(stats.leadersSkew(n2)) + .isCloseTo(BigDecimal.valueOf(-33.33), Percentage.withPercentage(1)); + assertThat(stats.leadersSkew(n3)) + .isCloseTo(BigDecimal.valueOf(-100), Percentage.withPercentage(1)); + assertThat(stats.leadersSkew(n4)) + .isCloseTo(BigDecimal.valueOf(-100), Percentage.withPercentage(1)); + } + +} diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java new file mode 100644 index 0000000000..497a9365d7 --- /dev/null +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/FieldsSelectorTest.java @@ -0,0 +1,53 @@ +package com.provectus.kafka.ui.service.masking.policies; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.provectus.kafka.ui.config.ClustersProperties; +import com.provectus.kafka.ui.exception.ValidationException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FieldsSelectorTest { + + @Test + void selectsFieldsDueToProvidedPattern() { + var properties = new ClustersProperties.Masking(); + properties.setFieldsNamePattern("f1|f2"); + + var selector = FieldsSelector.create(properties); + assertThat(selector.shouldBeMasked("f1")).isTrue(); + assertThat(selector.shouldBeMasked("f2")).isTrue(); + assertThat(selector.shouldBeMasked("doesNotMatchPattern")).isFalse(); + } + + @Test + void selectsFieldsDueToProvidedFieldNames() { + var properties = new ClustersProperties.Masking(); + properties.setFields(List.of("f1", "f2")); + + var selector = FieldsSelector.create(properties); + assertThat(selector.shouldBeMasked("f1")).isTrue(); + assertThat(selector.shouldBeMasked("f2")).isTrue(); + assertThat(selector.shouldBeMasked("notInAList")).isFalse(); + } + + @Test + void selectAllFieldsIfNoPatternAndNoNamesProvided() { + var properties = new ClustersProperties.Masking(); + + var selector = FieldsSelector.create(properties); + assertThat(selector.shouldBeMasked("anyPropertyName")).isTrue(); + } + + @Test + void throwsExceptionIfBothFieldListAndPatternProvided() { + var properties = new ClustersProperties.Masking(); + properties.setFieldsNamePattern("f1|f2"); + properties.setFields(List.of("f3", "f4")); + + assertThatThrownBy(() -> FieldsSelector.create(properties)) + .isInstanceOf(ValidationException.class); + } + +} diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/MaskTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/MaskTest.java index 9cb9701245..b33a26f300 100644 --- a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/MaskTest.java +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/MaskTest.java @@ -15,35 +15,35 @@ import org.junit.jupiter.params.provider.MethodSource; class MaskTest { - private static final List TARGET_FIELDS = List.of("id", "name"); + private static final FieldsSelector FIELDS_SELECTOR = fieldName -> List.of("id", "name").contains(fieldName); private static final List PATTERN = List.of("X", "x", "n", "-"); @ParameterizedTest @MethodSource - void testApplyToJsonContainer(List fields, ContainerNode original, ContainerNode expected) { - Mask policy = new Mask(fields, PATTERN); + void testApplyToJsonContainer(FieldsSelector selector, ContainerNode original, ContainerNode expected) { + Mask policy = new Mask(selector, PATTERN); assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected); } private static Stream testApplyToJsonContainer() { return Stream.of( Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"), parse("{ \"id\": \"nnn\", \"name\": { \"first\": \"Xxxxx\", \"surname\": \"Xxxxnnn-\"}}") ), Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"), parse("[{ \"id\": \"nnn\", \"f2\": 234}, { \"name\": \"n-n\", \"f2\": 345} ]") ), Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"), parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Xxxxnnn-\"}}") ), Arguments.of( - List.of(), + (FieldsSelector) (fieldName -> true), parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"), parse("{ \"outer\": { \"f1\": \"Xxxxx\", \"name\": \"Xxxxnnn-\"}}") ) @@ -57,7 +57,7 @@ class MaskTest { "null, xxxx" }) void testApplyToString(String original, String expected) { - Mask policy = new Mask(List.of(), PATTERN); + Mask policy = new Mask(fieldName -> true, PATTERN); assertThat(policy.applyToString(original)).isEqualTo(expected); } diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/RemoveTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/RemoveTest.java index 31ef4eb3c3..9393ea1c62 100644 --- a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/RemoveTest.java +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/RemoveTest.java @@ -15,39 +15,39 @@ import org.junit.jupiter.params.provider.MethodSource; class RemoveTest { - private static final List TARGET_FIELDS = List.of("id", "name"); + private static final FieldsSelector FIELDS_SELECTOR = fieldName -> List.of("id", "name").contains(fieldName); @ParameterizedTest @MethodSource - void testApplyToJsonContainer(List fields, ContainerNode original, ContainerNode expected) { - var policy = new Remove(fields); + void testApplyToJsonContainer(FieldsSelector fieldsSelector, ContainerNode original, ContainerNode expected) { + var policy = new Remove(fieldsSelector); assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected); } private static Stream testApplyToJsonContainer() { return Stream.of( Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"), parse("{}") ), Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"), parse("[{ \"f2\": 234}, { \"f2\": 345} ]") ), Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"), parse("{ \"outer\": { \"f1\": \"James\"}}") ), Arguments.of( - List.of(), + (FieldsSelector) (fieldName -> true), parse("{ \"outer\": { \"f1\": \"v1\", \"f2\": \"v2\", \"inner\" : {\"if1\": \"iv1\"}}}"), parse("{}") ), Arguments.of( - List.of(), + (FieldsSelector) (fieldName -> true), parse("[{ \"f1\": 123}, { \"f2\": \"1.2\"} ]"), parse("[{}, {}]") ) @@ -66,7 +66,7 @@ class RemoveTest { "null, null" }) void testApplyToString(String original, String expected) { - var policy = new Remove(List.of()); + var policy = new Remove(fieldName -> true); assertThat(policy.applyToString(original)).isEqualTo(expected); } -} \ No newline at end of file +} diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/ReplaceTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/ReplaceTest.java index f3ac69747b..9f2fcd90c4 100644 --- a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/ReplaceTest.java +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/masking/policies/ReplaceTest.java @@ -15,35 +15,35 @@ import org.junit.jupiter.params.provider.MethodSource; class ReplaceTest { - private static final List TARGET_FIELDS = List.of("id", "name"); + private static final FieldsSelector FIELDS_SELECTOR = fieldName -> List.of("id", "name").contains(fieldName); private static final String REPLACEMENT_STRING = "***"; @ParameterizedTest @MethodSource - void testApplyToJsonContainer(List fields, ContainerNode original, ContainerNode expected) { - var policy = new Replace(fields, REPLACEMENT_STRING); + void testApplyToJsonContainer(FieldsSelector fieldsSelector, ContainerNode original, ContainerNode expected) { + var policy = new Replace(fieldsSelector, REPLACEMENT_STRING); assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected); } private static Stream testApplyToJsonContainer() { return Stream.of( Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"), parse("{ \"id\": \"***\", \"name\": { \"first\": \"***\", \"surname\": \"***\"}}") ), Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"), parse("[{ \"id\": \"***\", \"f2\": 234}, { \"name\": \"***\", \"f2\": 345} ]") ), Arguments.of( - TARGET_FIELDS, + FIELDS_SELECTOR, parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"), parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"***\"}}") ), Arguments.of( - List.of(), + (FieldsSelector) (fieldName -> true), parse("{ \"outer\": { \"f1\": \"v1\", \"f2\": \"v2\", \"inner\" : {\"if1\": \"iv1\"}}}"), parse("{ \"outer\": { \"f1\": \"***\", \"f2\": \"***\", \"inner\" : {\"if1\": \"***\"}}}}") ) @@ -62,7 +62,7 @@ class ReplaceTest { "null, ***" }) void testApplyToString(String original, String expected) { - var policy = new Replace(List.of(), REPLACEMENT_STRING); + var policy = new Replace(fieldName -> true, REPLACEMENT_STRING); assertThat(policy.applyToString(original)).isEqualTo(expected); } -} \ No newline at end of file +} diff --git a/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml b/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml index 4bd3d2207c..2bafb05faa 100644 --- a/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml +++ b/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml @@ -2375,6 +2375,16 @@ components: type: number bytesOutPerSec: type: number + partitionsLeader: + type: integer + partitions: + type: integer + inSyncPartitions: + type: integer + partitionsSkew: + type: number + leadersSkew: + type: number required: - id @@ -3622,7 +3632,9 @@ components: type: array items: type: string - pattern: + fieldsNamePattern: + type: string + maskingCharsReplacement: type: array items: type: string diff --git a/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/services/ApiService.java b/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/services/ApiService.java index a041defc93..b4cc54a38f 100644 --- a/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/services/ApiService.java +++ b/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/services/ApiService.java @@ -36,29 +36,31 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti @Slf4j public class ApiService extends BaseSource { + private final ApiClient apiClient = new ApiClient().setBasePath(BASE_API_URL); + @SneakyThrows private TopicsApi topicApi() { - return new TopicsApi(new ApiClient().setBasePath(BASE_API_URL)); + return new TopicsApi(apiClient); } @SneakyThrows private SchemasApi schemaApi() { - return new SchemasApi(new ApiClient().setBasePath(BASE_API_URL)); + return new SchemasApi(apiClient); } @SneakyThrows private KafkaConnectApi connectorApi() { - return new KafkaConnectApi(new ApiClient().setBasePath(BASE_API_URL)); + return new KafkaConnectApi(apiClient); } @SneakyThrows private MessagesApi messageApi() { - return new MessagesApi(new ApiClient().setBasePath(BASE_API_URL)); + return new MessagesApi(apiClient); } @SneakyThrows private KsqlApi ksqlApi() { - return new KsqlApi(new ApiClient().setBasePath(BASE_API_URL)); + return new KsqlApi(apiClient); } @SneakyThrows diff --git a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java index d96bbb7f3a..3ce086ee7b 100644 --- a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java +++ b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java @@ -2,6 +2,7 @@ package com.provectus.kafka.ui.manualsuite.backlog; import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.BROKERS_SUITE_ID; import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.KSQL_DB_SUITE_ID; +import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.SCHEMAS_SUITE_ID; import static com.provectus.kafka.ui.qasesuite.BaseQaseTest.TOPICS_PROFILE_SUITE_ID; import static com.provectus.kafka.ui.utilities.qase.enums.State.TO_BE_AUTOMATED; @@ -35,37 +36,65 @@ public class SmokeBacklog extends BaseManualTest { } @Automation(state = TO_BE_AUTOMATED) - @Suite(id = KSQL_DB_SUITE_ID) - @QaseId(284) + @Suite(id = BROKERS_SUITE_ID) + @QaseId(331) @Test public void testCaseD() { } - @Automation(state = TO_BE_AUTOMATED) - @Suite(id = BROKERS_SUITE_ID) - @QaseId(331) - @Test - public void testCaseE() { - } - @Automation(state = TO_BE_AUTOMATED) @Suite(id = BROKERS_SUITE_ID) @QaseId(332) @Test - public void testCaseF() { + public void testCaseE() { } @Automation(state = TO_BE_AUTOMATED) @Suite(id = TOPICS_PROFILE_SUITE_ID) @QaseId(335) @Test - public void testCaseG() { + public void testCaseF() { } @Automation(state = TO_BE_AUTOMATED) @Suite(id = TOPICS_PROFILE_SUITE_ID) @QaseId(336) @Test + public void testCaseG() { + } + + @Automation(state = TO_BE_AUTOMATED) + @Suite(id = TOPICS_PROFILE_SUITE_ID) + @QaseId(343) + @Test public void testCaseH() { } + + @Automation(state = TO_BE_AUTOMATED) + @Suite(id = KSQL_DB_SUITE_ID) + @QaseId(344) + @Test + public void testCaseI() { + } + + @Automation(state = TO_BE_AUTOMATED) + @Suite(id = SCHEMAS_SUITE_ID) + @QaseId(345) + @Test + public void testCaseJ() { + } + + @Automation(state = TO_BE_AUTOMATED) + @Suite(id = SCHEMAS_SUITE_ID) + @QaseId(346) + @Test + public void testCaseK() { + } + + @Automation(state = TO_BE_AUTOMATED) + @Suite(id = TOPICS_PROFILE_SUITE_ID) + @QaseId(347) + @Test + public void testCaseL() { + } } diff --git a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/TopicsTest.java b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/TopicsTest.java index 76f8506deb..758827e21b 100644 --- a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/TopicsTest.java +++ b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/TopicsTest.java @@ -92,4 +92,28 @@ public class TopicsTest extends BaseManualTest { @Test public void testCaseN() { } + + @Automation(state = NOT_AUTOMATED) + @QaseId(337) + @Test + public void testCaseO() { + } + + @Automation(state = NOT_AUTOMATED) + @QaseId(339) + @Test + public void testCaseP() { + } + + @Automation(state = NOT_AUTOMATED) + @QaseId(341) + @Test + public void testCaseQ() { + } + + @Automation(state = NOT_AUTOMATED) + @QaseId(342) + @Test + public void testCaseR() { + } } diff --git a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/WizardTest.java b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/WizardTest.java index 9621104b1a..c74c1ba6f0 100644 --- a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/WizardTest.java +++ b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/suite/WizardTest.java @@ -14,4 +14,16 @@ public class WizardTest extends BaseManualTest { @Test public void testCaseA() { } + + @Automation(state = NOT_AUTOMATED) + @QaseId(338) + @Test + public void testCaseB() { + } + + @Automation(state = NOT_AUTOMATED) + @QaseId(340) + @Test + public void testCaseC() { + } } diff --git a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/ksqldb/KsqlDbTest.java b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/ksqldb/KsqlDbTest.java index c4bbe0def4..22ef931bf1 100644 --- a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/ksqldb/KsqlDbTest.java +++ b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/ksqldb/KsqlDbTest.java @@ -1,5 +1,6 @@ package com.provectus.kafka.ui.smokesuite.ksqldb; +import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlMenuTabs.STREAMS; import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_TABLES; import static com.provectus.kafka.ui.pages.panels.enums.MenuItem.KSQL_DB; import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; @@ -39,17 +40,21 @@ public class KsqlDbTest extends BaseTest { FIRST_TABLE.getName(), SECOND_TABLE.getName())); } - @QaseId(86) + @QaseId(284) @Test(priority = 1) - public void clearResultsForExecutedRequest() { - navigateToKsqlDbAndExecuteRequest(SHOW_TABLES.getQuery()); + public void streamsAndTablesVisibilityCheck() { + naviSideBar + .openSideMenu(KSQL_DB); + ksqlDbList + .waitUntilScreenReady(); SoftAssert softly = new SoftAssert(); - softly.assertTrue(ksqlQueryForm.areResultsVisible(), "areResultsVisible()"); - softly.assertAll(); - ksqlQueryForm - .clickClearResultsBtn(); - softly.assertFalse(ksqlQueryForm.areResultsVisible(), "areResultsVisible()"); + softly.assertTrue(ksqlDbList.getTableByName(FIRST_TABLE.getName()).isVisible(), "getTableByName()"); + softly.assertTrue(ksqlDbList.getTableByName(SECOND_TABLE.getName()).isVisible(), "getTableByName()"); softly.assertAll(); + ksqlDbList + .openDetailsTab(STREAMS) + .waitUntilScreenReady(); + Assert.assertTrue(ksqlDbList.getStreamByName(DEFAULT_STREAM.getName()).isVisible(), "getStreamByName()"); } @QaseId(276) @@ -68,11 +73,31 @@ public class KsqlDbTest extends BaseTest { navigateToKsqlDbAndExecuteRequest(SHOW_TABLES.getQuery()); SoftAssert softly = new SoftAssert(); softly.assertTrue(ksqlQueryForm.areResultsVisible(), "areResultsVisible()"); - softly.assertTrue(ksqlQueryForm.getItemByName(FIRST_TABLE.getName()).isVisible(), "getItemByName()"); - softly.assertTrue(ksqlQueryForm.getItemByName(SECOND_TABLE.getName()).isVisible(), "getItemByName()"); + softly.assertTrue(ksqlQueryForm.getItemByName(FIRST_TABLE.getName()).isVisible(), + String.format("getItemByName(%s)", FIRST_TABLE.getName())); + softly.assertTrue(ksqlQueryForm.getItemByName(SECOND_TABLE.getName()).isVisible(), + String.format("getItemByName(%s)", SECOND_TABLE.getName())); softly.assertAll(); } + @QaseId(86) + @Test(priority = 4) + public void clearResultsForExecutedRequest() { + navigateToKsqlDbAndExecuteRequest(SHOW_TABLES.getQuery()); + SoftAssert softly = new SoftAssert(); + softly.assertTrue(ksqlQueryForm.areResultsVisible(), "areResultsVisible()"); + softly.assertAll(); + ksqlQueryForm + .clickClearResultsBtn(); + softly.assertFalse(ksqlQueryForm.areResultsVisible(), "areResultsVisible()"); + softly.assertAll(); + } + + @AfterClass(alwaysRun = true) + public void afterClass() { + TOPIC_NAMES_LIST.forEach(topicName -> apiService.deleteTopic(topicName)); + } + @Step private void navigateToKsqlDbAndExecuteRequest(String query) { naviSideBar @@ -85,9 +110,4 @@ public class KsqlDbTest extends BaseTest { .setQuery(query) .clickExecuteBtn(query); } - - @AfterClass(alwaysRun = true) - public void afterClass() { - TOPIC_NAMES_LIST.forEach(topicName -> apiService.deleteTopic(topicName)); - } } diff --git a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/topics/TopicsTest.java b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/topics/TopicsTest.java index ad20f595a4..bad6a9fcde 100644 --- a/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/topics/TopicsTest.java +++ b/kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/topics/TopicsTest.java @@ -486,11 +486,7 @@ public class TopicsTest extends BaseTest { topicDetails .waitUntilScreenReady(); TOPIC_LIST.add(topicToCopy); - SoftAssert softly = new SoftAssert(); - softly.assertTrue(topicDetails.isAlertWithMessageVisible(SUCCESS, "Topic successfully created."), - "isAlertWithMessageVisible()"); - softly.assertTrue(topicDetails.isTopicHeaderVisible(topicToCopy.getName()), "isTopicHeaderVisible()"); - softly.assertAll(); + Assert.assertTrue(topicDetails.isTopicHeaderVisible(topicToCopy.getName()), "isTopicHeaderVisible()"); } @AfterClass(alwaysRun = true) diff --git a/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx b/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx index 30b3df8a56..5b3a24cdb7 100644 --- a/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx +++ b/kafka-ui-react-app/src/components/Connect/List/ActionsCell.tsx @@ -1,26 +1,41 @@ import React from 'react'; -import { FullConnectorInfo } from 'generated-sources'; +import { + Action, + ConnectorAction, + ConnectorState, + FullConnectorInfo, + ResourceType, +} from 'generated-sources'; import { CellContext } from '@tanstack/react-table'; import { ClusterNameRoute } from 'lib/paths'; import useAppParams from 'lib/hooks/useAppParams'; import { Dropdown, DropdownItem } from 'components/common/Dropdown'; -import { useDeleteConnector } from 'lib/hooks/api/kafkaConnect'; +import { + useDeleteConnector, + useUpdateConnectorState, +} from 'lib/hooks/api/kafkaConnect'; import { useConfirm } from 'lib/hooks/useConfirm'; +import { useIsMutating } from '@tanstack/react-query'; +import { ActionDropdownItem } from 'components/common/ActionComponent'; const ActionsCell: React.FC> = ({ row, }) => { - const { connect, name } = row.original; - + const { connect, name, status } = row.original; const { clusterName } = useAppParams(); - + const mutationsNumber = useIsMutating(); + const isMutating = mutationsNumber > 0; const confirm = useConfirm(); const deleteMutation = useDeleteConnector({ clusterName, connectName: connect, connectorName: name, }); - + const stateMutation = useUpdateConnectorState({ + clusterName, + connectName: connect, + connectorName: name, + }); const handleDelete = () => { confirm( <> @@ -31,8 +46,66 @@ const ActionsCell: React.FC> = ({ } ); }; + // const stateMutation = useUpdateConnectorState(routerProps); + const resumeConnectorHandler = () => + stateMutation.mutateAsync(ConnectorAction.RESUME); + const restartConnectorHandler = () => + stateMutation.mutateAsync(ConnectorAction.RESTART); + + const restartAllTasksHandler = () => + stateMutation.mutateAsync(ConnectorAction.RESTART_ALL_TASKS); + + const restartFailedTasksHandler = () => + stateMutation.mutateAsync(ConnectorAction.RESTART_FAILED_TASKS); + return ( + {status.state === ConnectorState.PAUSED && ( + + Resume + + )} + + Restart Connector + + + Restart All Tasks + + + Restart Failed Tasks + Remove Connector diff --git a/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx b/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx index 9de28f38ff..82b4aab212 100644 --- a/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx +++ b/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx @@ -9,7 +9,11 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render, WithRoute } from 'lib/testHelpers'; import { clusterConnectConnectorPath, clusterConnectorsPath } from 'lib/paths'; -import { useConnectors, useDeleteConnector } from 'lib/hooks/api/kafkaConnect'; +import { + useConnectors, + useDeleteConnector, + useUpdateConnectorState, +} from 'lib/hooks/api/kafkaConnect'; const mockedUsedNavigate = jest.fn(); const mockDelete = jest.fn(); @@ -22,6 +26,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('lib/hooks/api/kafkaConnect', () => ({ useConnectors: jest.fn(), useDeleteConnector: jest.fn(), + useUpdateConnectorState: jest.fn(), })); const clusterName = 'local'; @@ -42,6 +47,10 @@ describe('Connectors List', () => { (useConnectors as jest.Mock).mockImplementation(() => ({ data: connectors, })); + const restartConnector = jest.fn(); + (useUpdateConnectorState as jest.Mock).mockImplementation(() => ({ + mutateAsync: restartConnector, + })); }); it('renders', async () => { diff --git a/kafka-ui-react-app/src/components/Schemas/Diff/Diff.styled.ts b/kafka-ui-react-app/src/components/Schemas/Diff/Diff.styled.ts index 8f178d320d..520f9f6c8a 100644 --- a/kafka-ui-react-app/src/components/Schemas/Diff/Diff.styled.ts +++ b/kafka-ui-react-app/src/components/Schemas/Diff/Diff.styled.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { Button } from 'components/common/Button/Button'; export const DiffWrapper = styled.div` align-items: stretch; @@ -81,3 +82,6 @@ export const DiffTile = styled.div` export const DiffVersionsSelect = styled.div` width: 0.625em; `; +export const BackButton = styled(Button)` + margin: 10px 9px; +`; diff --git a/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx b/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx index d0016b46b4..05b1373ab6 100644 --- a/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx @@ -20,6 +20,7 @@ import useAppParams from 'lib/hooks/useAppParams'; import PageHeading from 'components/common/PageHeading/PageHeading'; import * as S from './Diff.styled'; +import { BackButton } from './Diff.styled'; export interface DiffProps { versions: SchemaSubject[]; @@ -77,6 +78,13 @@ const Diff: React.FC = ({ versions, areVersionsFetched }) => { backText="Schema Registry" backTo={clusterSchemasPath(clusterName)} /> + navigate(-1)} + > + Back + {areVersionsFetched ? ( diff --git a/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx b/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx index 0c614cf661..2a9429eef1 100644 --- a/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx @@ -3,6 +3,7 @@ import Diff, { DiffProps } from 'components/Schemas/Diff/Diff'; import { render, WithRoute } from 'lib/testHelpers'; import { screen } from '@testing-library/react'; import { clusterSchemaComparePath } from 'lib/paths'; +import userEvent from '@testing-library/user-event'; import { versions } from './fixtures'; @@ -142,4 +143,24 @@ describe('Diff', () => { expect(select).toHaveTextContent(versions[0].version); }); }); + + describe('Back button', () => { + beforeEach(() => { + setupComponent({ + areVersionsFetched: true, + versions, + }); + }); + + it('back button is appear', () => { + const backButton = screen.getAllByRole('button', { name: 'Back' }); + expect(backButton[0]).toBeInTheDocument(); + }); + + it('click on back button', () => { + const backButton = screen.getAllByRole('button', { name: 'Back' }); + userEvent.click(backButton[0]); + expect(screen.queryByRole('Back')).not.toBeInTheDocument(); + }); + }); }); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/AddEditFilterContainer.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/AddEditFilterContainer.tsx index 757b6e171d..557db159ba 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/AddEditFilterContainer.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/AddEditFilterContainer.tsx @@ -27,7 +27,7 @@ export interface AddEditFilterContainerProps { inputDisplayNameDefaultValue?: string; inputCodeDefaultValue?: string; isAdd?: boolean; - submitCallback?: (values: AddMessageFilters) => Promise; + submitCallback?: (values: AddMessageFilters) => void; } const AddEditFilterContainer: React.FC = ({ diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx index fb4e258cca..dd5cfae748 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx @@ -142,6 +142,8 @@ const Message: React.FC = ({ timestampType={timestampType} keySize={keySize} contentSize={valueSize} + keySerde={keySerde} + valueSerde={valueSerde} /> )} diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx index 93616ca432..d1237ba0d4 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx @@ -3,7 +3,6 @@ import EditorViewer from 'components/common/EditorViewer/EditorViewer'; import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; import { SchemaType, TopicMessageTimestampTypeEnum } from 'generated-sources'; import { formatTimestamp } from 'lib/dateTimeHelpers'; -import { useSearchParams } from 'react-router-dom'; import * as S from './MessageContent.styled'; @@ -17,6 +16,8 @@ export interface MessageContentProps { timestampType?: TopicMessageTimestampTypeEnum; keySize?: number; contentSize?: number; + keySerde?: string; + valueSerde?: string; } const MessageContent: React.FC = ({ @@ -27,12 +28,10 @@ const MessageContent: React.FC = ({ timestampType, keySize, contentSize, + keySerde, + valueSerde, }) => { const [activeTab, setActiveTab] = React.useState('content'); - const [searchParams] = useSearchParams(); - const keyFormat = searchParams.get('keySerde') || ''; - const valueFormat = searchParams.get('valueSerde') || ''; - const activeTabContent = () => { switch (activeTab) { case 'content': @@ -110,7 +109,7 @@ const MessageContent: React.FC = ({ Key Serde - {keyFormat} + {keySerde} Size: @@ -120,7 +119,7 @@ const MessageContent: React.FC = ({ Value Serde - {valueFormat} + {valueSerde} Size: diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/__tests__/MessageContent.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/__tests__/MessageContent.spec.tsx index 91310a30e4..d76455242c 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/__tests__/MessageContent.spec.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/__tests__/MessageContent.spec.tsx @@ -20,6 +20,8 @@ const setupWrapper = (props?: Partial) => { headers={{ header: 'test' }} timestamp={new Date(0)} timestampType={TopicMessageTimestampTypeEnum.CREATE_TIME} + keySerde="SchemaRegistry" + valueSerde="Avro" {...props} /> @@ -27,42 +29,20 @@ const setupWrapper = (props?: Partial) => { ); }; -const proto = - 'syntax = "proto3";\npackage com.provectus;\n\nmessage TestProtoRecord {\n string f1 = 1;\n int32 f2 = 2;\n}\n'; - global.TextEncoder = TextEncoder; -const searchParamsContentAVRO = new URLSearchParams({ - keySerde: 'SchemaRegistry', - valueSerde: 'AVRO', - limit: '100', -}); - -const searchParamsContentJSON = new URLSearchParams({ - keySerde: 'SchemaRegistry', - valueSerde: 'JSON', - limit: '100', -}); - -const searchParamsContentPROTOBUF = new URLSearchParams({ - keySerde: 'SchemaRegistry', - valueSerde: 'PROTOBUF', - limit: '100', -}); describe('MessageContent screen', () => { beforeEach(() => { - render(setupWrapper(), { - initialEntries: [`/messages?${searchParamsContentAVRO}`], - }); + render(setupWrapper()); }); - describe('renders', () => { - it('key format in document', () => { + describe('Checking keySerde and valueSerde', () => { + it('keySerde in document', () => { expect(screen.getByText('SchemaRegistry')).toBeInTheDocument(); }); - it('content format in document', () => { - expect(screen.getByText('AVRO')).toBeInTheDocument(); + it('valueSerde in document', () => { + expect(screen.getByText('Avro')).toBeInTheDocument(); }); }); @@ -98,42 +78,3 @@ describe('MessageContent screen', () => { }); }); }); - -describe('checking content type depend on message type', () => { - it('renders component with message having JSON type', () => { - render( - setupWrapper({ - messageContent: '{"data": "test"}', - }), - { initialEntries: [`/messages?${searchParamsContentJSON}`] } - ); - expect(screen.getByText('JSON')).toBeInTheDocument(); - }); - it('renders component with message having AVRO type', () => { - render( - setupWrapper({ - messageContent: '{"data": "test"}', - }), - { initialEntries: [`/messages?${searchParamsContentAVRO}`] } - ); - expect(screen.getByText('AVRO')).toBeInTheDocument(); - }); - it('renders component with message having PROTOBUF type', () => { - render( - setupWrapper({ - messageContent: proto, - }), - { initialEntries: [`/messages?${searchParamsContentPROTOBUF}`] } - ); - expect(screen.getByText('PROTOBUF')).toBeInTheDocument(); - }); - it('renders component with message having no type which is equal to having PROTOBUF type', () => { - render( - setupWrapper({ - messageContent: '', - }), - { initialEntries: [`/messages?${searchParamsContentPROTOBUF}`] } - ); - expect(screen.getByText('PROTOBUF')).toBeInTheDocument(); - }); -}); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx index 483c41d053..d2750abf7d 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx @@ -8,15 +8,29 @@ export const Wrapper = styled.div` export const Columns = styled.div` margin: -0.75rem; margin-bottom: 0.75rem; + display: flex; + flex-direction: column; + padding: 0.75rem; + gap: 8px; @media screen and (min-width: 769px) { display: flex; } `; - -export const Column = styled.div` - flex-basis: 0; - flex-grow: 1; - flex-shrink: 1; - padding: 0.75rem; +export const Flex = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + @media screen and (max-width: 1200px) { + flex-direction: column; + } +`; +export const FlexItem = styled.div` + width: 18rem; + @media screen and (max-width: 1450px) { + width: 50%; + } + @media screen and (max-width: 1200px) { + width: 100%; + } `; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx index 9450e512ad..bacfa76c93 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx @@ -4,6 +4,7 @@ import { RouteParamsClusterTopic } from 'lib/paths'; import { Button } from 'components/common/Button/Button'; import Editor from 'components/common/Editor/Editor'; import Select, { SelectOption } from 'components/common/Select/Select'; +import Switch from 'components/common/Switch/Switch'; import useAppParams from 'lib/hooks/useAppParams'; import { showAlert } from 'lib/errorHandling'; import { useSendMessage, useTopicDetails } from 'lib/hooks/api/topics'; @@ -26,9 +27,12 @@ interface FormType { partition: number; keySerde: string; valueSerde: string; + keepContents: boolean; } -const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { +const SendMessage: React.FC<{ closeSidebar: () => void }> = ({ + closeSidebar, +}) => { const { clusterName, topicName } = useAppParams(); const { data: topic } = useTopicDetails({ clusterName, topicName }); const { data: serdes = {} } = useSerdes({ @@ -47,11 +51,13 @@ const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { handleSubmit, formState: { isSubmitting }, control, + setValue, } = useForm({ mode: 'onChange', defaultValues: { ...defaultValues, partition: Number(partitionOptions[0].value), + keepContents: false, }, }); @@ -62,6 +68,7 @@ const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { content, headers, partition, + keepContents, }: FormType) => { let errors: string[] = []; @@ -110,7 +117,11 @@ const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => { keySerde, valueSerde, }); - onSubmit(); + if (!keepContents) { + setValue('key', ''); + setValue('content', ''); + closeSidebar(); + } } catch (e) { // do nothing } @@ -120,7 +131,7 @@ const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
- + Partition void }> = ({ onSubmit }) => { /> )} /> - - - Key Serde + + + + Key Serde + ( + + )} + /> + + +
( - - )} - /> - + Keep contents +
- - +
Key void }> = ({ onSubmit }) => { /> )} /> - - +
+
Value void }> = ({ onSubmit }) => { /> )} /> - +
- +
Headers void }> = ({ onSubmit }) => { /> )} /> - +