From d27a75a10bb57cf7386eac62ea382fec1e1b15a9 Mon Sep 17 00:00:00 2001 From: Oleksiy Lukin Date: Mon, 1 Aug 2022 12:45:58 +0200 Subject: [PATCH] implement basic kafka dev ui --- bom/application/pom.xml | 5 + extensions/kafka-client/deployment/pom.xml | 8 + .../deployment/KafkaBuildTimeConfig.java | 8 + .../deployment/KafkaBuildTimeUiConfig.java | 28 +++ .../client/deployment/KafkaProcessor.java | 212 +++++++++++++++- .../resources/dev-templates/embedded.html | 3 + extensions/kafka-client/pom.xml | 1 + extensions/kafka-client/runtime/pom.xml | 5 + .../kafka/client/health/KafkaHealthCheck.java | 30 +-- .../client/runtime/KafkaAdminClient.java | 76 ++++++ .../runtime/KafkaRuntimeConfigProducer.java | 7 +- .../ui/AbstractHttpRequestHandler.java | 99 ++++++++ .../client/runtime/ui/KafkaTopicClient.java | 50 ++++ .../client/runtime/ui/KafkaUiHandler.java | 113 +++++++++ .../client/runtime/ui/KafkaUiRecorder.java | 49 ++++ .../kafka/client/runtime/ui/KafkaUiUtils.java | 135 ++++++++++ .../request/KafkaCreateTopicRequest.java | 28 +++ .../ui/model/response/KafkaAclEntry.java | 34 +++ .../ui/model/response/KafkaAclInfo.java | 37 +++ .../ui/model/response/KafkaClusterInfo.java | 37 +++ .../runtime/ui/model/response/KafkaInfo.java | 31 +++ .../runtime/ui/model/response/KafkaNode.java | 32 +++ .../runtime/ui/model/response/KafkaTopic.java | 40 +++ extensions/kafka-client/ui/pom.xml | 212 ++++++++++++++++ .../ui/src/main/webapp/favicon.ico | Bin 0 -> 4286 bytes .../ui/src/main/webapp/index.html | 232 ++++++++++++++++++ .../kafka-client/ui/src/main/webapp/logo.png | Bin 0 -> 31710 bytes .../webapp/pages/accessControlListPage.js | 49 ++++ .../ui/src/main/webapp/pages/nodesPage.js | 48 ++++ .../ui/src/main/webapp/pages/schemaPage.js | 16 ++ .../main/webapp/quarkus_icon_rgb_reverse.svg | 1 + .../src/main/webapp/util/contentManagement.js | 29 +++ .../ui/src/main/webapp/web/web.js | 22 ++ 33 files changed, 1647 insertions(+), 30 deletions(-) create mode 100644 extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeUiConfig.java create mode 100644 extensions/kafka-client/deployment/src/main/resources/dev-templates/embedded.html create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/AbstractHttpRequestHandler.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaTopicClient.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiHandler.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiRecorder.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiUtils.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaCreateTopicRequest.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclEntry.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclInfo.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaClusterInfo.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaInfo.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaNode.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaTopic.java create mode 100644 extensions/kafka-client/ui/pom.xml create mode 100644 extensions/kafka-client/ui/src/main/webapp/favicon.ico create mode 100644 extensions/kafka-client/ui/src/main/webapp/index.html create mode 100644 extensions/kafka-client/ui/src/main/webapp/logo.png create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/schemaPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/quarkus_icon_rgb_reverse.svg create mode 100644 extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/web/web.js diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 53ba2092b32e2..f778f70e5b2a1 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1265,6 +1265,11 @@ quarkus-kafka-client-deployment ${project.version} + + io.quarkus + quarkus-kafka-client-ui + ${project.version} + io.quarkus quarkus-kafka-streams diff --git a/extensions/kafka-client/deployment/pom.xml b/extensions/kafka-client/deployment/pom.xml index c1312bd48e115..78ae0ae217423 100644 --- a/extensions/kafka-client/deployment/pom.xml +++ b/extensions/kafka-client/deployment/pom.xml @@ -44,6 +44,14 @@ io.quarkus quarkus-caffeine-deployment + + io.quarkus + quarkus-vertx-http-deployment + + + io.quarkus + quarkus-kafka-client-ui + org.testcontainers testcontainers diff --git a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeConfig.java b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeConfig.java index b975b460e869b..ab52f4b2dc7a1 100644 --- a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeConfig.java +++ b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeConfig.java @@ -1,5 +1,6 @@ package io.quarkus.kafka.client.deployment; +import io.quarkus.runtime.annotations.ConfigDocSection; import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; @@ -28,4 +29,11 @@ public class KafkaBuildTimeConfig { */ @ConfigItem public KafkaDevServicesBuildTimeConfig devservices; + + /** + * Kafka UI configuration + */ + @ConfigItem + @ConfigDocSection + public KafkaBuildTimeUiConfig ui; } diff --git a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeUiConfig.java b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeUiConfig.java new file mode 100644 index 0000000000000..d8dfd5706a5d9 --- /dev/null +++ b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeUiConfig.java @@ -0,0 +1,28 @@ +package io.quarkus.kafka.client.deployment; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class KafkaBuildTimeUiConfig { + + /** + * The path where Kafka UI is available. + * The value `/` is not allowed as it blocks the application from serving anything else. + * By default, this URL will be resolved as a path relative to `${quarkus.http.non-application-root-path}`. + */ + @ConfigItem(defaultValue = "kafka-ui") + public String rootPath; + /** + * Whether or not to enable Kafka Dev UI in non-development native mode. + */ + @ConfigItem(name = "handlerpath", defaultValue = "kafka-admin") + public String handlerRootPath; + /** + * Always include the UI. By default, this will only be included in dev and test. + * Setting this to true will also include the UI in Prod + */ + @ConfigItem(defaultValue = "false") + public boolean alwaysInclude; + +} diff --git a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java index 41fb178c3ea4a..1557b9430add2 100644 --- a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java +++ b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java @@ -1,14 +1,21 @@ package io.quarkus.kafka.client.deployment; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Scanner; import java.util.Set; import java.util.function.Consumer; import java.util.logging.Level; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.security.auth.spi.LoginModule; @@ -70,10 +77,13 @@ import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.HotDeploymentWatchedFileBuildItem; import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.LogCategoryBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageSecurityProviderBuildItem; @@ -82,9 +92,11 @@ import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.logging.LogCleanupFilterBuildItem; import io.quarkus.deployment.pkg.NativeConfig; -import io.quarkus.kafka.client.runtime.KafkaBindingConverter; -import io.quarkus.kafka.client.runtime.KafkaRecorder; +import io.quarkus.kafka.client.runtime.*; import io.quarkus.kafka.client.runtime.KafkaRuntimeConfigProducer; +import io.quarkus.kafka.client.runtime.ui.KafkaTopicClient; +import io.quarkus.kafka.client.runtime.ui.KafkaUiRecorder; +import io.quarkus.kafka.client.runtime.ui.KafkaUiUtils; import io.quarkus.kafka.client.serialization.BufferDeserializer; import io.quarkus.kafka.client.serialization.BufferSerializer; import io.quarkus.kafka.client.serialization.JsonArrayDeserializer; @@ -95,7 +107,19 @@ import io.quarkus.kafka.client.serialization.JsonbSerializer; import io.quarkus.kafka.client.serialization.ObjectMapperDeserializer; import io.quarkus.kafka.client.serialization.ObjectMapperSerializer; +import io.quarkus.maven.dependency.GACT; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.smallrye.health.deployment.spi.HealthBuildItem; +import io.quarkus.vertx.http.deployment.BodyHandlerBuildItem; +import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; +import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; +import io.quarkus.vertx.http.deployment.webjar.WebJarBuildItem; +import io.quarkus.vertx.http.deployment.webjar.WebJarResourcesFilter; +import io.quarkus.vertx.http.deployment.webjar.WebJarResultsBuildItem; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; public class KafkaProcessor { @@ -144,6 +168,25 @@ public class KafkaProcessor { static final DotName PARTITION_ASSIGNER = DotName .createSimple("org.apache.kafka.clients.consumer.internals.PartitionAssignor"); + // For the UI + private static final GACT KAFKA_UI_WEBJAR_ARTIFACT_KEY = new GACT("io.quarkus", "quarkus-kafka-client-ui", null, "jar"); + private static final String KAFKA_UI_WEBJAR_STATIC_RESOURCES_PATH = "META-INF/resources/kafka-ui/"; + private static final String FILE_TO_UPDATE = "config.js"; + private static final String LINE_TO_UPDATE = "export const api = '"; + private static final String LINE_FORMAT = LINE_TO_UPDATE + "%s';"; + private static final String UI_LINE_TO_UPDATE = "export const ui = '"; + private static final String UI_LINE_FORMAT = UI_LINE_TO_UPDATE + "%s';"; + private static final String LOGO_LINE_TO_UPDATE = "export const logo = '"; + private static final String LOGO_LINE_FORMAT = LOGO_LINE_TO_UPDATE + "%s';"; + private static final String UI_LOGO_PATH = "logo.png"; + // UI brandibg + private static final String BRANDING_DIR = "META-INF/branding/"; + private static final String BRANDING_LOGO_GENERAL = BRANDING_DIR + "logo.png"; + private static final String BRANDING_LOGO_MODULE = BRANDING_DIR + "quarkus-kafka-client-ui.png"; + private static final String BRANDING_STYLE_GENERAL = BRANDING_DIR + "style.css"; + private static final String BRANDING_STYLE_MODULE = BRANDING_DIR + "quarkus-kafka-client-ui.css"; + private static final String BRANDING_FAVICON_GENERAL = BRANDING_DIR + "favicon.ico"; + private static final String BRANDING_FAVICON_MODULE = BRANDING_DIR + "quarkus-kafka-client-ui.ico"; @BuildStep FeatureBuildItem feature() { @@ -165,7 +208,8 @@ void silenceUnwantedConfigLogs(BuildProducer logClean List ignoredMessages = new ArrayList<>(); for (String ignoredConfigProperty : ignoredConfigProperties) { - ignoredMessages.add("The configuration '" + ignoredConfigProperty + "' was supplied but isn't a known config."); + ignoredMessages + .add("The configuration '" + ignoredConfigProperty + "' was supplied but isn't a known config."); } logCleanupFilters.produce(new LogCleanupFilterBuildItem("org.apache.kafka.clients.consumer.ConsumerConfig", @@ -478,4 +522,166 @@ void registerServiceBinding(Capabilities capabilities, KafkaBindingConverter.class.getName())); } } + + // Kafka UI related stuff + + @BuildStep + public AdditionalBeanBuildItem kafkaClientBeans() { + return AdditionalBeanBuildItem.builder() + .addBeanClass(KafkaAdminClient.class) + .addBeanClass(KafkaTopicClient.class) + .addBeanClass(KafkaUiUtils.class) + .setUnremovable() + .build(); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public void registerKafkaUiExecHandler( + BuildProducer routeProducer, + KafkaUiRecorder recorder, + LaunchModeBuildItem launchMode, + HttpRootPathBuildItem httpRootPathBuildItem, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, + KafkaBuildTimeConfig buildConfig, + BodyHandlerBuildItem bodyHandlerBuildItem, + ShutdownContextBuildItem shutdownContext) { + + if (shouldIncludeUi(launchMode, buildConfig)) { + String handlerPath = nonApplicationRootPathBuildItem.resolvePath(buildConfig.ui.handlerRootPath); + Handler executionHandler = recorder.kafkaControlHandler(); + HttpRootPathBuildItem.Builder requestBuilder = httpRootPathBuildItem.routeBuilder() + .routeFunction(handlerPath, recorder.routeFunction(bodyHandlerBuildItem.getHandler())) + .handler(executionHandler) + .routeConfigKey("quarkus.kafka-client-ui.root-path") + .displayOnNotFoundPage("Kafka UI Endpoint"); + + routeProducer.produce(requestBuilder.build()); + } + } + + @BuildStep + List uiBrandingFiles() { + return Stream.of(BRANDING_LOGO_GENERAL, + BRANDING_STYLE_GENERAL, + BRANDING_FAVICON_GENERAL, + BRANDING_LOGO_MODULE, + BRANDING_STYLE_MODULE, + BRANDING_FAVICON_MODULE).map(HotDeploymentWatchedFileBuildItem::new) + .collect(Collectors.toList()); + } + + @BuildStep + void getKafkaUiFinalDestination( + HttpRootPathBuildItem httpRootPath, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, + LaunchModeBuildItem launchMode, + KafkaBuildTimeConfig buildConfig, + BuildProducer webJarBuildProducer) { + + if (shouldIncludeUi(launchMode, buildConfig)) { + + if ("/".equals(buildConfig.ui.rootPath)) { + throw new ConfigurationException( + "quarkus.kafka-client-ui.root-path was set to \"/\", this is not allowed as it blocks the application from serving anything else.", + Collections.singleton("quarkus.kafka-client-ui.root-path")); + } + + String devUiPath = nonApplicationRootPathBuildItem.resolvePath("dev"); + String kafkaUiPath = nonApplicationRootPathBuildItem.resolvePath(buildConfig.ui.rootPath); + String kafkaHandlerPath = nonApplicationRootPathBuildItem.resolvePath(buildConfig.ui.handlerRootPath); + webJarBuildProducer.produce( + WebJarBuildItem.builder().artifactKey(KAFKA_UI_WEBJAR_ARTIFACT_KEY) + .root(KAFKA_UI_WEBJAR_STATIC_RESOURCES_PATH) + .filter(new WebJarResourcesFilter() { + @Override + public WebJarResourcesFilter.FilterResult apply(String fileName, InputStream file) + throws IOException { + if (fileName.endsWith(FILE_TO_UPDATE)) { + String content = new String(file.readAllBytes(), StandardCharsets.UTF_8); + content = updateUrl(content, kafkaHandlerPath, + LINE_TO_UPDATE, + LINE_FORMAT); + content = updateUrl(content, kafkaUiPath, + UI_LINE_TO_UPDATE, + UI_LINE_FORMAT); + content = updateUrl(content, + getLogoUrl(launchMode, kafkaUiPath + "/" + UI_LOGO_PATH, + kafkaUiPath + "/" + UI_LOGO_PATH), + LOGO_LINE_TO_UPDATE, + LOGO_LINE_FORMAT); + + return new WebJarResourcesFilter.FilterResult( + new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)), true); + } + + return new WebJarResourcesFilter.FilterResult(file, false); + } + }) + .build()); + } + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void registerKafkaUiHandler( + BuildProducer routeProducer, + KafkaUiRecorder recorder, + LaunchModeBuildItem launchMode, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, + KafkaBuildTimeConfig buildConfig, + WebJarResultsBuildItem webJarResultsBuildItem, + ShutdownContextBuildItem shutdownContext) { + + WebJarResultsBuildItem.WebJarResult result = webJarResultsBuildItem.byArtifactKey(KAFKA_UI_WEBJAR_ARTIFACT_KEY); + if (result == null) { + return; + } + + if (shouldIncludeUi(launchMode, buildConfig)) { + String kafkaUiPath = nonApplicationRootPathBuildItem.resolvePath(buildConfig.ui.rootPath); + String finalDestination = result.getFinalDestination(); + + Handler handler = recorder.uiHandler(finalDestination, + kafkaUiPath, result.getWebRootConfigurations(), shutdownContext); + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .route(buildConfig.ui.rootPath) + .displayOnNotFoundPage("Kafka UI") + .routeConfigKey("quarkus.kafka-client.ui.root-path") + .handler(handler) + .build()); + + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .route(buildConfig.ui.rootPath + "*") + .handler(handler) + .build()); + + } + } + + // In dev mode, when you click on the logo, you should go to Dev UI + private String getLogoUrl(LaunchModeBuildItem launchMode, String devUIValue, String defaultValue) { + if (launchMode.getLaunchMode().equals(LaunchMode.DEVELOPMENT)) { + return devUIValue; + } + return defaultValue; + } + + private String updateUrl(String original, String path, String lineStartsWith, String format) { + try (Scanner scanner = new Scanner(original)) { + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + if (line.trim().startsWith(lineStartsWith)) { + String newLine = String.format(format, path); + return original.replace(line.trim(), newLine); + } + } + } + + return original; + } + + private static boolean shouldIncludeUi(LaunchModeBuildItem launchMode, KafkaBuildTimeConfig config) { + return launchMode.getLaunchMode().isDevOrTest() || config.ui.alwaysInclude; + } } diff --git a/extensions/kafka-client/deployment/src/main/resources/dev-templates/embedded.html b/extensions/kafka-client/deployment/src/main/resources/dev-templates/embedded.html new file mode 100644 index 0000000000000..312a6c5268e02 --- /dev/null +++ b/extensions/kafka-client/deployment/src/main/resources/dev-templates/embedded.html @@ -0,0 +1,3 @@ + + + Kafka UI diff --git a/extensions/kafka-client/pom.xml b/extensions/kafka-client/pom.xml index a0e0885d2b170..dd7448eb06106 100644 --- a/extensions/kafka-client/pom.xml +++ b/extensions/kafka-client/pom.xml @@ -15,6 +15,7 @@ pom + ui deployment runtime diff --git a/extensions/kafka-client/runtime/pom.xml b/extensions/kafka-client/runtime/pom.xml index 50ac39873257c..0e037445a128e 100644 --- a/extensions/kafka-client/runtime/pom.xml +++ b/extensions/kafka-client/runtime/pom.xml @@ -59,6 +59,11 @@ provided + + io.quarkus + quarkus-vertx-http + + io.quarkus quarkus-junit5-internal diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/health/KafkaHealthCheck.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/health/KafkaHealthCheck.java index a0b7c6648caa7..e9b9a24bd265d 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/health/KafkaHealthCheck.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/health/KafkaHealthCheck.java @@ -1,43 +1,23 @@ package io.quarkus.kafka.client.health; -import java.util.HashMap; -import java.util.Map; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; import javax.enterprise.context.ApplicationScoped; -import javax.inject.Inject; -import org.apache.kafka.clients.admin.AdminClient; -import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.common.Node; import org.eclipse.microprofile.health.HealthCheck; import org.eclipse.microprofile.health.HealthCheckResponse; import org.eclipse.microprofile.health.HealthCheckResponseBuilder; import org.eclipse.microprofile.health.Readiness; -import io.smallrye.common.annotation.Identifier; +import io.quarkus.kafka.client.runtime.KafkaAdminClient; @Readiness @ApplicationScoped public class KafkaHealthCheck implements HealthCheck { - @Inject - @Identifier("default-kafka-broker") - Map config; - - private AdminClient client; - - @PostConstruct - void init() { - Map conf = new HashMap<>(config); - conf.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, "5000"); - client = AdminClient.create(conf); - } + KafkaAdminClient kafkaAdminClient; - @PreDestroy - void stop() { - client.close(); + public KafkaHealthCheck(KafkaAdminClient kafkaAdminClient) { + this.kafkaAdminClient = kafkaAdminClient; } @Override @@ -45,7 +25,7 @@ public HealthCheckResponse call() { HealthCheckResponseBuilder builder = HealthCheckResponse.named("Kafka connection health check").up(); try { StringBuilder nodes = new StringBuilder(); - for (Node node : client.describeCluster().nodes().get()) { + for (Node node : kafkaAdminClient.getCluster().nodes().get()) { if (nodes.length() > 0) { nodes.append(','); } diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java new file mode 100644 index 0000000000000..78f68171bab4f --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaAdminClient.java @@ -0,0 +1,76 @@ +package io.quarkus.kafka.client.runtime; + +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaCreateTopicRequest; +import io.smallrye.common.annotation.Identifier; +import org.apache.kafka.clients.admin.*; +import org.apache.kafka.common.acl.AccessControlEntryFilter; +import org.apache.kafka.common.acl.AclBinding; +import org.apache.kafka.common.acl.AclBindingFilter; +import org.apache.kafka.common.resource.ResourcePatternFilter; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.apache.kafka.clients.admin.*; +import org.apache.kafka.common.acl.AccessControlEntryFilter; +import org.apache.kafka.common.acl.AclBinding; +import org.apache.kafka.common.acl.AclBindingFilter; +import org.apache.kafka.common.resource.ResourcePatternFilter; + +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaCreateTopicRequest; +import io.smallrye.common.annotation.Identifier; + +@ApplicationScoped +public class KafkaAdminClient { + private static final int DEFAULT_ADMIN_CLIENT_TIMEOUT = 5000; + + @Inject + @Identifier("default-kafka-broker") + Map config; + + private AdminClient client; + + @PostConstruct + void init() { + Map conf = new HashMap<>(config); + conf.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, DEFAULT_ADMIN_CLIENT_TIMEOUT); + client = AdminClient.create(conf); + } + + @PreDestroy + void stop() { + client.close(); + } + + public DescribeClusterResult getCluster() { + return client.describeCluster(); + } + + public Collection getTopics() throws InterruptedException, ExecutionException { + return client.listTopics().listings().get(); + } + + public boolean deleteTopic(String name) { + Collection topics = new ArrayList<>(); + topics.add(name); + DeleteTopicsResult dtr = client.deleteTopics(topics); + return dtr.topicNameValues() != null; + } + + public boolean createTopic(KafkaCreateTopicRequest kafkaCreateTopicRq) { + var partitions = Optional.ofNullable(kafkaCreateTopicRq.getPartitions()).orElse(1); + var replications = Optional.ofNullable(kafkaCreateTopicRq.getReplications()).orElse((short) 1); + var newTopic = new NewTopic(kafkaCreateTopicRq.getTopicName(), partitions, replications); + + CreateTopicsResult ctr = client.createTopics(List.of(newTopic)); + return ctr.values() != null; + } + + public Collection getAclInfo() throws InterruptedException, ExecutionException { + AclBindingFilter filter = new AclBindingFilter(ResourcePatternFilter.ANY, AccessControlEntryFilter.ANY); + var options = new DescribeAclsOptions().timeoutMs(1_000); + return client.describeAcls(filter, options).values().get(); + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRuntimeConfigProducer.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRuntimeConfigProducer.java index 2be14e5717251..93e2ca309ab99 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRuntimeConfigProducer.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRuntimeConfigProducer.java @@ -17,7 +17,7 @@ public class KafkaRuntimeConfigProducer { // not "kafka.", because we also inspect env vars, which start with "KAFKA_" private static final String CONFIG_PREFIX = "kafka"; - + private static final String UI_CONFIG_PREFIX = CONFIG_PREFIX + ".ui"; private static final String GROUP_ID = "group.id"; @Produces @@ -29,7 +29,10 @@ public Map createKafkaRuntimeConfig(Config config, ApplicationCo for (String propertyName : config.getPropertyNames()) { String propertyNameLowerCase = propertyName.toLowerCase(); - if (!propertyNameLowerCase.startsWith(CONFIG_PREFIX)) { + if (propertyNameLowerCase.startsWith(UI_CONFIG_PREFIX)) { + config.getOptionalValue(propertyName, String.class).orElse(""); + } + if (!propertyNameLowerCase.startsWith(CONFIG_PREFIX) || propertyNameLowerCase.startsWith(UI_CONFIG_PREFIX)) { continue; } // Replace _ by . - This is because Kafka properties tend to use . and env variables use _ for every special diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/AbstractHttpRequestHandler.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/AbstractHttpRequestHandler.java new file mode 100644 index 0000000000000..bc83093041b33 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/AbstractHttpRequestHandler.java @@ -0,0 +1,99 @@ +package io.quarkus.kafka.client.runtime.ui; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ManagedContext; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; + +public abstract class AbstractHttpRequestHandler implements Handler { + private final CurrentIdentityAssociation currentIdentityAssociation; + private final CurrentVertxRequest currentVertxRequest; + private final ManagedContext currentManagedContext; + private final Handler currentManagedContextTerminationHandler; + + public AbstractHttpRequestHandler(CurrentIdentityAssociation currentIdentityAssociation, + CurrentVertxRequest currentVertxRequest) { + this.currentIdentityAssociation = currentIdentityAssociation; + this.currentVertxRequest = currentVertxRequest; + this.currentManagedContext = Arc.container().requestContext(); + this.currentManagedContextTerminationHandler = e -> currentManagedContext.terminate(); + } + + @Override + @SuppressWarnings("unchecked") // ignore currentManagedContextTerminationHandler types, just use Object + public void handle(final RoutingContext ctx) { + + if (currentManagedContext.isActive()) { + handleWithIdentity(ctx); + } else { + + currentManagedContext.activate(); + ctx.response() + .endHandler(currentManagedContextTerminationHandler) + .exceptionHandler(currentManagedContextTerminationHandler) + .closeHandler(currentManagedContextTerminationHandler); + + try { + handleWithIdentity(ctx); + } catch (Throwable t) { + currentManagedContext.terminate(); + throw t; + } + } + } + + public void doHandle(RoutingContext ctx) { + try { + HttpServerRequest request = ctx.request(); + + switch (request.method().name()) { + case "OPTIONS": + handleOptions(ctx); + break; + case "POST": + handlePost(ctx); + break; + case "GET": + handleGet(ctx); + break; + default: + ctx.next(); + break; + } + } catch (Exception e) { + ctx.fail(e); + } + } + + private void handleWithIdentity(final RoutingContext ctx) { + if (currentIdentityAssociation != null) { + QuarkusHttpUser existing = (QuarkusHttpUser) ctx.user(); + if (existing != null) { + SecurityIdentity identity = existing.getSecurityIdentity(); + currentIdentityAssociation.setIdentity(identity); + } else { + currentIdentityAssociation.setIdentity(QuarkusHttpUser.getSecurityIdentity(ctx, null)); + } + } + currentVertxRequest.setCurrent(ctx); + doHandle(ctx); + } + + public abstract void handlePost(RoutingContext event); + + public abstract void handleGet(RoutingContext event); + + public abstract void handleOptions(RoutingContext event); + + protected String getRequestPath(RoutingContext event) { + HttpServerRequest request = event.request(); + return request.path(); + } + + //TODO: service methods for HTTP requests +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaTopicClient.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaTopicClient.java new file mode 100644 index 0000000000000..a02d760185733 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaTopicClient.java @@ -0,0 +1,50 @@ +package io.quarkus.kafka.client.runtime.ui; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.common.TopicPartitionInfo; + +import io.smallrye.common.annotation.Identifier; + +@Singleton +public class KafkaTopicClient { + //TODO: inject me + private AdminClient adminClient; + + @Inject + @Identifier("default-kafka-broker") + Map config; + + @PostConstruct + void init() { + Map conf = new HashMap<>(config); + conf.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, "5000"); + adminClient = AdminClient.create(conf); + } + + public List partitions(String topicName) throws ExecutionException, InterruptedException { + return adminClient.describeTopics(List.of(topicName)) + .allTopicNames() + .get() + .values().stream() + .reduce((a, b) -> { + throw new IllegalStateException( + "Requested info about single topic, but got result of multiple: " + a + ", " + b); + }) + .orElseThrow(() -> new IllegalStateException( + "Requested info about a topic, but nothing found. Topic name: " + topicName)) + .partitions().stream() + .map(TopicPartitionInfo::partition) + .collect(Collectors.toList()); + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiHandler.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiHandler.java new file mode 100644 index 0000000000000..54f18f49a61ab --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiHandler.java @@ -0,0 +1,113 @@ + +package io.quarkus.kafka.client.runtime.ui; + +import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; + +import java.util.concurrent.ExecutionException; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.arc.Arc; +import io.quarkus.kafka.client.runtime.KafkaAdminClient; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaCreateTopicRequest; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; + +public class KafkaUiHandler extends AbstractHttpRequestHandler { + + public KafkaUiHandler(CurrentIdentityAssociation currentIdentityAssociation, CurrentVertxRequest currentVertxRequest) { + super(currentIdentityAssociation, currentVertxRequest); + } + + @Override + public void handlePost(RoutingContext event) { + if (event.body() == null) { + endResponse(event, BAD_REQUEST, "Request body is null"); + return; + } + var body = event.body().asJsonObject(); + if (body == null) { + endResponse(event, BAD_REQUEST, "Request JSON body is null"); + return; + } + var action = body.getString("action"); + + var message = "OK"; + var error = ""; + + var webUtils = kafkaWebUiUtils(); + var adminClient = kafkaAdminClient(); + + boolean res = false; + if (null != action) { + try { + switch (action) { + case "getInfo": + message = webUtils.toJson(webUtils.getKafkaInfo()); + res = true; + break; + case "getAclInfo": + message = webUtils.toJson(webUtils.getAclInfo()); + res = true; + break; + case "createTopic": + var topicCreateRq = event.body().asPojo(KafkaCreateTopicRequest.class); + res = adminClient.createTopic(topicCreateRq); + message = webUtils.toJson(webUtils.getTopics()); + break; + case "deleteTopic": + res = adminClient.deleteTopic(body.getString("key")); + message = "{}"; + res = true; + break; + case "getTopics": + message = webUtils.toJson(webUtils.getTopics()); + res = true; + break; + default: + break; + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } catch (ExecutionException ex) { + throw new RuntimeException(ex); + } + } + + if (res) { + endResponse(event, OK, message); + } else { + message = "ERROR: " + error; + endResponse(event, BAD_REQUEST, message); + } + } + + private void endResponse(RoutingContext event, HttpResponseStatus status, String message) { + event.response().setStatusCode(status.code()); + event.response().end(message); + } + + private KafkaUiUtils kafkaWebUiUtils() { + return Arc.container().instance(KafkaUiUtils.class).get(); + } + + @Override + public void handleGet(RoutingContext event) { + //TODO: move pure get requests processing here + HttpServerRequest request = event.request(); + String path = request.path(); + endResponse(event, OK, "GET method is not supported yet. Path is: " + path); + } + + @Override + public void handleOptions(RoutingContext event) { + endResponse(event, OK, "OPTION method is not supported yet"); + } + + private KafkaAdminClient kafkaAdminClient() { + return Arc.container().instance(KafkaAdminClient.class).get(); + } + +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiRecorder.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiRecorder.java new file mode 100644 index 0000000000000..21d89717ffcbc --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiRecorder.java @@ -0,0 +1,49 @@ +package io.quarkus.kafka.client.runtime.ui; + +import java.util.List; +import java.util.function.Consumer; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.runtime.ShutdownContext; +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.quarkus.vertx.http.runtime.devmode.FileSystemStaticHandler; +import io.quarkus.vertx.http.runtime.webjar.WebJarStaticHandler; +import io.vertx.core.Handler; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.RoutingContext; + +/** + * Handles requests from kafka UI and html/js of UI + */ +@Recorder +public class KafkaUiRecorder { + + public Handler kafkaControlHandler() { + return new KafkaUiHandler(getCurrentIdentityAssociation(), + Arc.container().instance(CurrentVertxRequest.class).get()); + } + + public Consumer routeFunction(Handler bodyHandler) { + return route -> route.handler(bodyHandler); + } + + public Handler uiHandler(String finalDestination, String uiPath, + List webRootConfigurations, + ShutdownContext shutdownContext) { + WebJarStaticHandler handler = new WebJarStaticHandler(finalDestination, uiPath, webRootConfigurations); + shutdownContext.addShutdownTask(new ShutdownContext.CloseRunnable(handler)); + return handler; + } + + private CurrentIdentityAssociation getCurrentIdentityAssociation() { + InstanceHandle identityAssociations = Arc.container() + .instance(CurrentIdentityAssociation.class); + if (identityAssociations.isAvailable()) { + return identityAssociations.get(); + } + return null; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiUtils.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiUtils.java new file mode 100644 index 0000000000000..31ac51173e07e --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/KafkaUiUtils.java @@ -0,0 +1,135 @@ +package io.quarkus.kafka.client.runtime.ui; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import javax.inject.Singleton; + +import org.apache.kafka.clients.admin.DescribeClusterResult; +import org.apache.kafka.clients.admin.TopicListing; +import org.apache.kafka.common.Node; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.kafka.client.runtime.KafkaAdminClient; +import io.quarkus.kafka.client.runtime.ui.model.response.KafkaClusterInfo; +import io.quarkus.kafka.client.runtime.ui.model.response.KafkaInfo; +import io.quarkus.kafka.client.runtime.ui.model.response.KafkaNode; +import io.quarkus.kafka.client.runtime.ui.model.response.KafkaTopic; + +@Singleton +public class KafkaUiUtils { + + private final KafkaAdminClient kafkaAdminClient; + + private final KafkaTopicClient kafkaTopicClient; + + private final ObjectMapper objectMapper; + + public KafkaUiUtils(KafkaAdminClient kafkaAdminClient, KafkaTopicClient kafkaTopicClient, ObjectMapper objectMapper) { + this.kafkaAdminClient = kafkaAdminClient; + this.kafkaTopicClient = kafkaTopicClient; + this.objectMapper = objectMapper; + } + + public KafkaInfo getKafkaInfo() throws ExecutionException, InterruptedException { + var clusterInfo = getClusterInfo(); + var broker = clusterInfo.getController().asFullNodeName(); + var topics = getTopics(); + return new KafkaInfo(broker, clusterInfo, topics); + } + + public KafkaClusterInfo getClusterInfo() throws ExecutionException, InterruptedException { + return clusterInfo(kafkaAdminClient.getCluster()); + } + + private KafkaNode kafkaNode(Node node) { + return new KafkaNode(node.host(), node.port(), node.idString()); + } + + private KafkaClusterInfo clusterInfo(DescribeClusterResult dcr) throws InterruptedException, ExecutionException { + var controller = kafkaNode(dcr.controller().get()); + var nodes = new ArrayList(); + for (var node : dcr.nodes().get()) { + nodes.add(kafkaNode(node)); + } + var aclOperations = dcr.authorizedOperations().get(); + + var aclOperationsStr = new StringBuilder(); + if (aclOperations != null) { + for (var operation : dcr.authorizedOperations().get()) { + if (aclOperationsStr.length() == 0) { + aclOperationsStr.append(", "); + } + aclOperationsStr.append(operation.name()); + } + } else { + aclOperationsStr = new StringBuilder("NONE"); + } + + return new KafkaClusterInfo( + dcr.clusterId().get(), + controller, + nodes, + aclOperationsStr.toString()); + } + + public List getTopics() throws InterruptedException, ExecutionException { + var res = new ArrayList(); + for (TopicListing tl : kafkaAdminClient.getTopics()) { + res.add(kafkaTopic(tl)); + } + return res; + } + + private KafkaTopic kafkaTopic(TopicListing tl) throws ExecutionException, InterruptedException { + var partitions = partitions(tl.name()); + return new KafkaTopic( + tl.name(), + tl.topicId().toString(), + partitions.size(), + tl.isInternal()); + } + + public Collection partitions(String topicName) throws ExecutionException, InterruptedException { + return kafkaTopicClient.partitions(topicName); + } + + public KafkaAclInfo getAclInfo() throws InterruptedException, ExecutionException { + var clusterInfo = clusterInfo(kafkaAdminClient.getCluster()); + var entries = new ArrayList(); + //TODO: fix it after proper error message impl + try { + var acls = kafkaAdminClient.getAclInfo(); + for (var acl : acls) { + var entry = new KafkaAclEntry( + acl.entry().operation().name(), + acl.entry().principal(), + acl.entry().permissionType().name(), + acl.pattern().toString()); + entries.add(entry); + } + } catch (Exception e) { + // this mostly means that ALC controller is absent + } + return new KafkaAclInfo( + clusterInfo.getId(), + clusterInfo.getController().asFullNodeName(), + clusterInfo.getAclOperations(), + entries); + } + + public String toJson(Object o) { + String res; + try { + res = objectMapper.writeValueAsString(o); + } catch (JsonProcessingException ex) { + //FIXME: + res = ""; + } + return res; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaCreateTopicRequest.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaCreateTopicRequest.java new file mode 100644 index 0000000000000..8fbe12f9c2500 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaCreateTopicRequest.java @@ -0,0 +1,28 @@ +package io.quarkus.kafka.client.runtime.ui.model.request; + +public class KafkaCreateTopicRequest { + private String topicName; + private Integer partitions; + private Short replications; + + public KafkaCreateTopicRequest() { + } + + public KafkaCreateTopicRequest(String topicName, Integer partitions, Short replications) { + this.topicName = topicName; + this.partitions = partitions; + this.replications = replications; + } + + public String getTopicName() { + return topicName; + } + + public Integer getPartitions() { + return partitions; + } + + public Short getReplications() { + return replications; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclEntry.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclEntry.java new file mode 100644 index 0000000000000..b32a0d729f6b7 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclEntry.java @@ -0,0 +1,34 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +public class KafkaAclEntry { + private String operation; + private String principal; + private String perm; + private String pattern; + + public KafkaAclEntry() { + } + + public KafkaAclEntry(String operation, String principal, String perm, String pattern) { + this.operation = operation; + this.principal = principal; + this.perm = perm; + this.pattern = pattern; + } + + public String getOperation() { + return operation; + } + + public String getPrincipal() { + return principal; + } + + public String getPerm() { + return perm; + } + + public String getPattern() { + return pattern; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclInfo.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclInfo.java new file mode 100644 index 0000000000000..4e53287f220b7 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaAclInfo.java @@ -0,0 +1,37 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +import java.util.ArrayList; +import java.util.List; + +public class KafkaAclInfo { + private String clusterId; + private String broker; + private String aclOperations; + private List entries = new ArrayList<>(); + + public KafkaAclInfo() { + } + + public KafkaAclInfo(String clusterId, String broker, String aclOperations, List entries) { + this.clusterId = clusterId; + this.broker = broker; + this.aclOperations = aclOperations; + this.entries = entries; + } + + public String getClusterId() { + return clusterId; + } + + public String getBroker() { + return broker; + } + + public String getAclOperations() { + return aclOperations; + } + + public List getEntries() { + return entries; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaClusterInfo.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaClusterInfo.java new file mode 100644 index 0000000000000..71e8e67c69b11 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaClusterInfo.java @@ -0,0 +1,37 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +import java.util.ArrayList; +import java.util.List; + +public class KafkaClusterInfo { + private String id; + private KafkaNode controller; + private List nodes = new ArrayList<>(); + private String aclOperations; + + public KafkaClusterInfo() { + } + + public KafkaClusterInfo(String id, KafkaNode controller, List nodes, String aclOperations) { + this.id = id; + this.controller = controller; + this.nodes = nodes; + this.aclOperations = aclOperations; + } + + public String getId() { + return id; + } + + public KafkaNode getController() { + return controller; + } + + public List getNodes() { + return nodes; + } + + public String getAclOperations() { + return aclOperations; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaInfo.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaInfo.java new file mode 100644 index 0000000000000..d095170b8bdf8 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaInfo.java @@ -0,0 +1,31 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +import java.util.List; + +public class KafkaInfo { + private String broker; + private KafkaClusterInfo clusterInfo; + private List topics; + + public KafkaInfo() { + } + + public KafkaInfo(String broker, KafkaClusterInfo clusterInfo, List topics) { + this.broker = broker; + this.clusterInfo = clusterInfo; + this.topics = topics; + } + + public String getBroker() { + return broker; + } + + public List getTopics() { + return topics; + } + + public KafkaClusterInfo getClusterInfo() { + return clusterInfo; + } + +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaNode.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaNode.java new file mode 100644 index 0000000000000..137645a7c29ee --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaNode.java @@ -0,0 +1,32 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +public class KafkaNode { + private String host; + private int port; + private String id; + + public KafkaNode() { + } + + public KafkaNode(String host, int port, String id) { + this.host = host; + this.port = port; + this.id = id; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public String getId() { + return id; + } + + public String asFullNodeName() { + return host + ":" + port; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaTopic.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaTopic.java new file mode 100644 index 0000000000000..b678b50afc344 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaTopic.java @@ -0,0 +1,40 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +public class KafkaTopic { + private String name; + private String topicId; + private int partitionsCount; + private boolean internal; + + public KafkaTopic() { + } + + public KafkaTopic(String name, String topicId, int partitionsCount, boolean internal) { + this.name = name; + this.topicId = topicId; + this.partitionsCount = partitionsCount; + this.internal = internal; + } + + public String getName() { + return name; + } + + public String getTopicId() { + return topicId; + } + + public int getPartitionsCount() { + return partitionsCount; + } + + public boolean isInternal() { + return internal; + } + + public String toString() { + StringBuilder sb = new StringBuilder(name); + sb.append(" : ").append(topicId); + return sb.toString(); + } +} diff --git a/extensions/kafka-client/ui/pom.xml b/extensions/kafka-client/ui/pom.xml new file mode 100644 index 0000000000000..31960a9ee918e --- /dev/null +++ b/extensions/kafka-client/ui/pom.xml @@ -0,0 +1,212 @@ + + + 4.0.0 + + + io.quarkus + quarkus-kafka-client-parent + 999-SNAPSHOT + + + quarkus-kafka-client-ui + jar + Quarkus - Kafka - Client - UI + + + kafka-ui + 0.9.15 + 1.9.1 + + + + + + org.webjars + bootstrap + provided + + + org.webjars + font-awesome + provided + + + org.webjars + jquery + provided + + + org.webjars + bootstrap-multiselect + ${bootstrap-mutiliselect.version} + + + org.webjars.npm + bootstrap-icons + ${bootstrap-icons.version} + + + + + + + + maven-resources-plugin + + + copy-web + generate-sources + + copy-resources + + + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui} + + + ${basedir}/src/main/webapp + + **/*.* + + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.3.0 + + + install-js + generate-sources + + unpack + + + + + + org.webjars + bootstrap + ${webjar.bootstrap.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/css/ + **/bootstrap.min.css, **/bootstrap.min.css.map + + + + + + org.webjars + bootstrap + ${webjar.bootstrap.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/js/ + **/bootstrap.bundle.min.js, **/bootstrap.bundle.min.js.map + + + + + + org.webjars + bootstrap-multiselect + ${bootstrap-mutiliselect.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/js/ + **/bootstrap-multiselect.js + + + + + + org.webjars + bootstrap-multiselect + ${bootstrap-mutiliselect.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/css/ + **/bootstrap-multiselect.css + + + + + + org.webjars.npm + bootstrap-icons + ${bootstrap-icons.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/css/ + **/font/bootstrap-icons.css + + + + + + org.webjars.npm + bootstrap-icons + ${bootstrap-icons.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/css/fonts/ + **/font/fonts/ + + + + + + + + org.webjars + jquery + ${webjar.jquery.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/js/ + **/jquery.min.js, **/jquery.min.js.map + + + + + + + org.webjars + font-awesome + ${webjar.font-awesome.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/fontawesome/css + **/css/all.min.css + + + + + + org.webjars + font-awesome + ${webjar.font-awesome.version} + jar + true + ${project.build.directory}/classes/META-INF/resources/${path.kafkaui}/fontawesome/webfonts + **/webfonts/**.* + + + + + + + + + + + + + + + diff --git a/extensions/kafka-client/ui/src/main/webapp/favicon.ico b/extensions/kafka-client/ui/src/main/webapp/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b4ef4208a6f489de1c17dd3791a3691ae77dee0d GIT binary patch literal 4286 zcmeH~OGs2v7{|XECtu~LmF8KXiW zL}V0H3yD@?4`w0gq6cmw(o~dL)FP#^`FFlMoI7`%nH=YHqh?{~h(y>l*+ zPW&Y&6aS}&QABY>lrBW5C|?Ncl_uu?H}4Uc?kqBVyIIiq{z{&8;#MKV^`fN26boTp zk>1)Y^Li!iWlT+Uuz@{dGOtV4Kpu=xrYO>OA}HiL2FB=Q(4LlNP<;dbX+I z^Lm%2o%4ksx=N3c);)N0E@wfu04e#{5t>xyIhyTW%=0VKN%wl+WSdrP;8N7Sai79j0 zviYym5vs~sF*Z60*(6_HPmYhFx%QOEzvDd|sQhOp8PR`s2VOtDCs9g2pU=wPb_;gV zTSU@Umpb&P{`sBSQMn0Iw;jKOzVf*~O34lr-9-q+EBo+=e|&cWK40If)|Qq(Jw1)4 zrY1#rEF1gp-~&bU9f?%qH=pyjR>a`Ou?!^!%(vNWFq_Tj>gvMW+?*mjW*yHt_;zfe zZUj@Dngf6Q*VfPGga}-1s6|Ul3#?YFbXVD{>69P?PObAR`;-iPy-&*x9B<;94P=g%dbzqRKlAI6@}-%I-b@PR3x6OZ2? vB1e1% + + + + kafka-devUI + + + + + + + + + + +
+ + +
+
+
+ + + + + + + + + + + + +
Topic NameIdPartitions countNumber of msg
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + +
Offset + + Timestamp KeyValue
+
+ +
+
+ + +
+
+ Schema registry is not implemeted yet.
+
+
+
+ + + + + + + + + + + + + +
StateIdCoordinatorProtocolMembersLag(Sum)
+
+
+
+
+ + + + + + + + + + + + +
Member IDHostPartitionsLag(Sum)
+
+
+
+
+
+ Kafka cluster id: 
+ Controller node (broker): 
+ ACL operations: 
+
+
+

Access Control Lists

+
+ + + + + + + + + + + +
OperationPrinicipalPermissionsPattern
+
+
+
+
+
+ Kafka cluster id: 
+ Controller node (broker): 
+ ACL operations: 
+
+
+

Cluster nodes

+
+ + + + + + + + + + +
IdHostPort
+
+
+
+
+ + + + + + + diff --git a/extensions/kafka-client/ui/src/main/webapp/logo.png b/extensions/kafka-client/ui/src/main/webapp/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6a1626104eb98f63a7dd134135784fbb0eca1d0e GIT binary patch literal 31710 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 literal 0 HcmV?d00001 diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js new file mode 100644 index 0000000000000..2e396ec39874a --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js @@ -0,0 +1,49 @@ +import {doPost, errorPopUp} from "../web/web.js"; +import {createTableItem} from "../util/contentManagement.js"; +import {toggleSpinner} from "../util/spinner.js"; + +export default class AccessControlListPage{ + constructor(containerId) { + this.containerId = containerId; + Object.getOwnPropertyNames(AccessControlListPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + + open() { + const req = { + action: "getAclInfo" + }; + doPost(req, (data) => { + let that = this; + setTimeout(function () { + that.updateInfo(data); + toggleSpinner(that.containerId); + }, 2000); + }, data => { + errorPopUp("Error getting Kafka ACL info: ", data); + }); + } + + updateInfo(data) { + $('#acluster-id').html(data.clusterId); + $('#acluster-controller').html(data.broker); + $('#acluster-acl').html(data.aclOperations); + + const acls = data.entires; + let aclTable = $('#acl-table tbody'); + aclTable.empty(); + for (let i = 0; i < acls.length; i++) { + const e = acls[i]; + let tableRow = $(""); + tableRow.append(createTableItem(e.operation)); + tableRow.append(createTableItem(e.prinipal)); + tableRow.append(createTableItem(e.perm)); + tableRow.append(createTableItem(e.pattern)); + aclTable.append(tableRow); + } + } +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js new file mode 100644 index 0000000000000..ad8675344e22b --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js @@ -0,0 +1,48 @@ +import {doPost, errorPopUp} from "../web/web.js"; +import {createTableItem} from "../util/contentManagement.js"; +import {toggleSpinner} from "../util/spinner.js"; + +export default class NodesPage { + constructor(containerId) { + this.containerId = containerId; + Object.getOwnPropertyNames(NodesPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + open() { + const req = { + action: "getInfo" + }; + doPost(req, (data) => { + let that = this; + setTimeout(function () { + that.updateInfo(data); + toggleSpinner(that.containerId); + }, 2000); + }, data => { + errorPopUp("Error getting Kafka info: ", data); + }); + toggleSpinner(this.containerId); + } + + updateInfo(data) { + $('#cluster-id').html(data.clusterInfo.id); + $('#cluster-controller').html(data.broker); + $('#cluster-acl').html(data.clusterInfo.aclOperations); + + const nodes = data.clusterInfo.nodes; + let clusterNodesTable = $('#cluster-table tbody'); + clusterNodesTable.empty(); + for (let i = 0; i < nodes.length; i++) { + const d = nodes[i]; + let tableRow = $(""); + tableRow.append(createTableItem(d.id)); + tableRow.append(createTableItem(d.host)); + tableRow.append(createTableItem(d.port)); + clusterNodesTable.append(tableRow); + } + } +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/schemaPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/schemaPage.js new file mode 100644 index 0000000000000..82b3f5f8d108c --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/schemaPage.js @@ -0,0 +1,16 @@ +export default class SchemaPage{ + constructor(containerId) { + this.containerId = containerId; + Object.getOwnPropertyNames(SchemaPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + // TODO: stub. must be implemented by all pages + open(){ + + } + +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/quarkus_icon_rgb_reverse.svg b/extensions/kafka-client/ui/src/main/webapp/quarkus_icon_rgb_reverse.svg new file mode 100644 index 0000000000000..1969e1e886af3 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/quarkus_icon_rgb_reverse.svg @@ -0,0 +1 @@ +quarkus_icon_rgb_1024px_reverse \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js b/extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js new file mode 100644 index 0000000000000..50df67b1ccef4 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js @@ -0,0 +1,29 @@ +export function createTableItem(text) { + return $("", { + text: text + }); +} + +export function createIcon(iconClass) { + return $("") + .addClass("bi") + .addClass(iconClass); +} + +export function showItem(selector){ + selector.addClass("shown") + .removeClass("hidden"); +} + +export function hideItem(selector){ + selector.addClass("hidden") + .removeClass("shown"); +} + +export function toggleItem(selector) { + if (selector.hasClass("shown")) { + hideItem(selector); + } else { + showItem(selector); + } +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/web/web.js b/extensions/kafka-client/ui/src/main/webapp/web/web.js new file mode 100644 index 0000000000000..6ba79b5c19720 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/web/web.js @@ -0,0 +1,22 @@ +import {api} from "../config.js" + +export function doPost(data, successCallback, errorCallback) { + $.ajax({ + url: api, + type: 'POST', + data: JSON.stringify(data), + contentType: "application/json; charset=utf-8", + dataType: 'json', + context: this, + success: (data) => successCallback(data), + error: (data, errorType, errorObj) => errorCallback(data, errorType, errorObj) + }); +} + +export function errorPopUp() { + let message = ""; + for (let i = 0; i < arguments.length; i++) { + message += arguments[i] + " "; + } + alert(message); +}