From 1a8b36b287a5b2fd4d3725c7aee27cb08d270a2d Mon Sep 17 00:00:00 2001 From: Oleksiy Lukin Date: Mon, 1 Aug 2022 12:45:58 +0200 Subject: [PATCH 1/3] 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 83a9594b3528c..d3902993aa765 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 zcmb5Wc|4T+8#n$L6N;oxi4s~)Wl3}r6=tTrLyJljLa9i$60**8I;DlQkc!Ntl#(rb zmYEhy#K^u6Stk2B7-o5{d(`=!^SqwlAHP4&tJBOqpXIvV*ZW%UZkim}BO|p`3Lzx( z=iZ%%5t;@6I}6R0fIrAe8du?uIc|H8yCWq18}=WrqB($v&~o(W&TXcbBm0_df|~6g zubs?T{=28gjrOrazSpB3K0TqC{Z=RQ-BkyjONmO~8m^{)ewkC@_Dw!B`e~lp3(w9| zFXY5x-sX~M2VOOuVwUcGkt07_bFW%rQtB@0lN(Px+fk^tHtzS2OPwmuoeEUBdn)YR zXolI~>%tEk1V@e%*gSzh;eK^vT=!(vbktTsF4LPjxG_CF{UMx|ayI*u;E#RH*2zS{ zsPFfKy7vyhpLj#p6%OhnZf*ybLMF?`y*qdVjugc<%f7xG~}DGrSITpYO@(gR=wA6k@!P7@yrF{D8Z_oMXr9v(wj!e zg#0RT5q?BiMWBe0r1SN7)9uVq*9jb=$&F}U$yb$%blXAmUxX~88#UH^Pa<|I27usQ zX0O}1!M6S{9jX}Vs)uiWs4EwTC^DBPJQ)@H?7#iB>46yO)8}WxVwDsQd{Js~^Ascc z6e&d&>5wwaJ6yT1l&0WwT=wC)$BsSej!eqryhJ8P?llpW$X0`r2wm^@J1w#9k*di- zDYU7wU(S3Uf4U(Lq(M4&VD}2!4~we;#Yi%rGx#YNhP?9O{;{{hGUc=f%}5+IWO{6D zoPAZe!`Vg}A==2DT`Ndtq+Rg2iPb2zvQZYHDw8s)N0rJZhEhoGTff{y0W6fj#e-(< zHsZV5%@}dIseV#uwfhj9W$A$xw3UmH(Qh9DxKD&1%+`Z~%Xja2Zb-7VowEq-?tA0P zQ*o}Aorlnneqi%#1w(Plf(zAfs8+*NiJ(VfjM4Zi&3@rM+yq{cgG5t2}y z;*A~^?g-{SgD;9N{BD8oz|XxQ#-P9^m;8DYnXs{~w%K=G#$TKqe<_sl#e)opd{((w z8x6+s51K@~Bo@{3sTU^UGzxG0x_s7@**e`*M3&|Ka?|aW!rV09VOW~uxzz<9nG_Q7 z93{=!E`U$eckFFlqlTPJ#-_9;YhWANA0y6`bM;7Z72Xz~(8KYFu9J{LEiFIc-w0FPRn zv`L+{FV!R4(i=}})EF<+6DIHg38^JUO|v$COIOM9&_i(GtubT%f!IyVitRrP7S<;n zweY}xu=wi)TzdI3P+jWF`Q=X((L5pSWz}vQDQEd?SP!l2X8FPIkW0rUqjTiSwX<}l zBq-SVkz$hGnpsVa9r=za0_Vr*WW{t|La%>ON;wf$So-4PGs@9o^hkaRu9b}PG>H~gktEa?X(QqtA_8(*^zhgl%7j4l2`_-!g9Z!VyBa1;-b=1RCc^RVLjJE>qe^w>) z{%}EG!-u@YV7IU!o|8d^7^R8~tNdux?|F?@oyXmsf|DS+{plmP-QIXcHTLD@#CuP} z_8i8J)g4-qcZhcaJ~UZ1{r#)Kr5&!g8{F&c!$$f!crcgNiMKC8$3bw`N?jE}-z6B4 zA}BYN-`T7$TW#WsW(&Z0C^CwU%UhFv{VjM&&k`R83|^0ZTp6_Mw{r$qyx+DOIEMsr6b z7)?lM37;_#vjLxlo1ZJBFDcRh!J&k7_xZ=s)%Q5<&?VQfCv3}_GW_wR5lkI(t4V9b z=6;u;bYPNGh`O?GmVzl95BvX%7zj4>H;TS_uXI6z^{z1&XFG-+D5${s>4yv}OvZe* zm}cE2@Y(KDUXl439iA@ehe!Yn|$u2yGshW+w9lf zij!kDa?PYsMwr2DSjDOSVOdASdZd7@fnpje_A=s|&`>{i+R*cr_M`i+At@oImEt;6 zh1RpbmU(mXi2WtZG*RrUwnZ4nk&Vt}=wY9jejL~w=USu`aXCMaZ-30}uu+LxjQcRV zdXfc^LBg8DMiVbX$3Ii&R8}e6_cJ3V-xH3Vl|n~6Z{xNsaV_^NvT51p01H)p9#kq? zY3jdH(BbMlQNK^Z_LDtFWP*cWi8t#+PyI7Jkz1syvy7&LxyIF-bsdWr^P$3x5 zT(guR6A6&&%c}q0G=_m~-(|ZCN`a$4e#7`zA^x%i^@jb$%!Yx@pIGJbFVS8h+ws*A zqe_VQ&cwk#@pLNCPJ#T(72 z`iajQ4f;JlrR0bqy^A^tJe!P*5Qmx?2Y7}&q&!c5S)Fc*%7rZJ5U^~Q27Nf0=@Ekx z;wfK?-s|?<-m~bE@2&lgCv}IxwBmv~ zWx7{7-5Q7Oz1g`6Zk%cyi966ZZZ?^llB0m6Q>wD+dP#hF&Bn6VyiI*KQGy) zL2}P!2PKNrl*(R(ew_w6CfF0o=ISu_OCw+B%d1NN#Edv$ZJE@&k5A6As)^Ov7%RH* z>5mVrFTJ!_bS_44{^Pb6s~w}_m{aB~fQrMKFaz*&t0I-2biWrz3(*tyi^m{b#_rGc zA2cal-V<-h&Jw6J`Y%}A>H5Z>CR&4)ia0O1gBUkL3>}mx@xs$G#giLpkcgGiT)N=eVG4vyCUh6l^W_hH@#iw2Bt&p*=37M zzY#|@kWPpVxNF<_k(y814aL#Yj~r}u?5}JioNaW!^tbtI8}r~04AK@mogKojt9s&$f{__W88gLvEj4}FIUslDw!o8ph zwxw zw}2NjAS9ZmUg)KIpwPWwhg;{xgqFo5e>v_eR&^r7{OdTevWD1*Vk!DgVx*Y9y=sP0 z1#5t+QqJ{H&S}I~{b|KvvZe;pE}-gY2oCmhKX9kdIgK1!Bvw|xmR5X`N?yiOd3Oj^ zzkqL@WjVs2{mKKxV~I-S>Hyr)`_sJXYsI0;X&X@W8a$#Yk6h(kOqD>U+krACh!H>y zjvr(YWvcG!VV{+^koyRZ@@^+pXk~Pcx*x^Lio;j+@?5m%`TXT7Na5y>QKqEus@XDH zKq`2d+7FOYr35mPAQiQOJZn-iR!t9~9_KyuiE%GGynO#!?Y&s8)h)=@Tt~`PgJfby zfHwJ$;QdiS+Zpft#a;HmIX-`|p%O_uRN-6W02A_>5!~5enOV9T1yiNjJ+kEne<1Wk zjhIO_wtJ8=E?*Z54xpdxxtWL8xexJ5Y1k-EIbvJpg4Ohf9CUk8hozI#R;93Mi?{=4u;91csYMT3SVs{O1(Z8QO3P>MsP3t4O2m@ z`}R=jpNOQAOdKjdutlZyW|8D0_(7?>zhFxcj_@Nr8tle{J16?ncM?s!Bw z-+#xC;zxP(Ko)Tp_zT<%rf_bMDWj&MydufXOSz~)j28X6?eUM}i&#&LBS(MPXd1pS!$_0k*7;0svLX*a>a;Bmp4 zgOJkK_k3`GuXirvak%&-HSF76gv$mG3f6?|G@1^2zsx&+VA25@Oh`L0rFbqJCPl_mA=qLP(+y^{LR;}{MA zEo~z%ZDSbNW+%Sf%$&nz7HBR(3UgqipoTj?E?7U5B{jmyF7oC9u(S_@3{4(3!vU1I zsKoQHmp$XR0Q@Zb2RPN+aH%sTlF{}nRcy^H(f;3R`D~D#iMRz;4!OEP*RAYM`7D6F zyd59xD|QwfI`%%tkHSh5U)P}212TB|-BQ@BD@#!a8PINxeCYn7qwD=gjE=L@a*z3T z{KaFi+)+@4>2bo|r~B<5?3o&^=){|B`^KgwPOH2lBGbo!qn=hw6%CqipeS|rIg{0< zLMg^@^uCCTLTtih-9DBbg1T0niod|uSqynR1IOFkfv=qadP-KERU~P9I+{sT0s81c zRl#s84~~hDS=Z4fxU!Fcb}SMZh8)y~zyM>j_pDN}Z8-49T)3E$zW`#0D#*E!&Nfi{ z3~?y@{bQ86u|}5i(gbex=lCuHk@`Y&x_zUuRV7%$1g68hl-g5Krnk# zq4yP2g;H^?tzjHH4%?vs+VqfPL9 zaLp*LrcmO5qGH{=4K=PS8nz>v$0G0zDa{*nhfXgN3zBi7DfDL0V1snnfWo=jRu5`= zK@^@KkXG%alJf@0u>ddhCzwS};WOAfeI2I;^6YQyLC8CSS0Tg0zzR1CK8G8@IsQJy zbAK;vNlz3t5u6I@+8@XKy*2dkF?@cRwTET|U^UP*7_r!sIy2&5Q*rW5%M9=T!4 zu(cb(xgRHxJmAxJ;LM$ZKodz9vOz;zUy2t=s%{(OGMDs9BB&uD6p^7dg1fVO1&P3z z4W}kdsyOjvrO^%=gbV2dwyEM@kAG?dd}n12V8^!Xfa^3xDe;xZIO!AvP1|oi^A3PA zcD)x4yyWdeCX3;B3PFA(|6erj(fl5`;Ko%tfs7R9W8c2@v-MvSykGgIAF{*ittgo4 z>=gt$jz;5XYfgi=cI&|RWdQ=t`|E)*oSi=31^e!3gMH^7{D(!XZGt$K{7m3M73~B#?iD^tZ*pJek9`FB) z%u&!Ikd(=tpA4dFsInX=`xre}?(!B?ZUh!*l8}2()@_{>F1T3o#*9Y9v?P(me%R~H z3YEJPuaduT*L2{ms@X)v3?)Rl{{^7YQ@0>@xWuCj`$^~Iaj<&YO28PUryl5oyWG$` z+>bJ29O%uT*h=SZK<{ukqjd4l9>S*gZcwd}M433CbQ6C`@LN{Z*5@EP$UjB|_{VMr z3ZW@ufNd0m(qH}`HXd-L{0u>ME*ZW6)Y7a+u1x=A5yPJ3D}d;2U;$MjVAfSf2ox&D zA}dwhV!3FBL`DK|6rcNW7LKPXINU(a5v*7`#?|NHNx@mz=ixJcb&OUZQ842^%jbfG z%9mk&HF>sF(_sQ>jLW9b19S8cZ3}h??_YFYhYtKcR?pf# zD0ci9g;;@d{UQW?Pc+$hg9TiaQj2L1Y>Hkx4WDB2Za9fqM`=6nz0g;n!cPv6gxosy zDpaE#?u8|1$O;n4L!&CfKB1sEg!P_h36gSv3LJ8{c`e&IYwX_B5)E=jf9DAyPiTB3pTj)4dm&>${%$n{SH!o~yxX}5^zqi8GD{{+tO#iFA z@yE9lPh9@ch!vyZKuaCZ^!1O`%L>N6uk|ztk0J|WPUuak+4y#TO*oQ^@8-Jt3BFr~ zlY9H2I)$NDW-MlyY*FvestnI_XS=#w9-`MITJu^GRLGqV!pj7FJB^lwsN4V(>vG~G z!?iMSz}KfUxu0G)yxXENub%4E->;WJAEOG|@2L25+ch-osUtOrA_0zrBC%PRS6fLP zxSTLKW~lWdZkm^&4#J%N%&vIdVtRpFO&(v2qeuW7QNX5E_n*}*SC-gaSqyqQJq})G-icOY~fW|UV18QYF0+5W=a;>;XCmNYb_ws zHO(eYag3qR)F&YN z(2jb2vuS?;nfqa5d*YqkqxE;`jhvbo*1+Yq2(n?+D9$>2XnQVM0mW{B<0~ZlH4`Ve z%tH<7Y#Z@g$!SzD*&C@2cn3o6G{IhZ%tZOqCV)r;RUX`=xfPijze#wuj# z0a+G_b7w4V_wo?Vbh-b*M8A_fc4N!l^No#pUFTitS^T~V^9=rv5*3>by7Olk_qr-P zTdBm9^L}gdU$yZ+a9J!W!1!-FWl(lDNG09y6R5_LUtjMTryEMCC*MT%?Ezf|F z462d+V*0Tns8m3=1v*idnZ04gzMfItoNpI^7GIp zOtE-KWr#VWk56?Cq%_*7_}WK!G(Yib@v&=sY-iIT_z62(H-KHMZYM!_kTtrU&J@N> z|RjNfE_fxU6q-kQb!0-L46S79@_tNB3QI|IAT(-R@_*b(C~x35_Q zP_NI*8L1n$**mUsFI-SbZW?}2C_J?b@Zp5TO-|bfMuixUeDxO;Oa)sjD?S><9q{kp zC=c*ubOF~eBszKpRDSD0tuBI)%_5%*-=tA{J~6T$l9wo90b9#a8c~8Vqqc?6fYcQ4{5m#1vPWs}FNLwNoNk#|^a=JaEN?GCh|H;tzu3Z$W=#?It` zET&MpoydC0qAy)1Q2uZErPUJ=jE)}}(QiceQ&b>$=DJx!nqj%IQ zfJ1GB^Q4A?T;oCKzzZRLRtK2P$vyAMB{`mL`DF(M<44p&AjpTE;CAIprM+@5o@16B z2_HR1%x-bBs8W8KTtKv*moZ1}$bQd;ZFj@L_nigEox!*N^a=TdY5 z8MNg8(3V>>wb$oA_@R9RVL3!<05F=peK?XTlkr!HitjFtzb+#xcPA7}LMV$p5emq{ zyb#EuG*#Edt3D_#gSC{&MMQ6&mRScLmFs{obUvndy?q(dJl&Vy#CCnFlA#eI^OcMK z@jgCc<+I_jDvP`U5nKJw_jo?o=F=axG5Y{#-U~K;n0vcH48`gLH8^TaaR0IUd4jps z|8qJQ36wAiS^^v|_G!L8Id|&Wbkx8EmwvA%y>Z*_lm9n{(tgaO25I*Sr}*7>*th7s z*2fq81)-~*?Hz@`W&yxG43*NAiPaH*=+38DU|G!w$na}3QG@W#eT$)2;hW?D(~sxn zt^0T#uEemibpgqDL48V~K9h5!jW&6a(>(csshF$8uuE-K8TycJ$EXDG-zkaFim{`< zQ1NTcPy@2V_-TxL!~-6{s0qAISjxeAEu;=%6~zd=5;c!~?R(`!&Ruv)51y)DO9+qh z{|6uS&WqrPH-%ND^CQ(Lc~s-d|4)f5+bJ3+FG-fV|uD*o+i`Xu(>FO(2`G8guZ zh@yuAF%w-_#|XIZzi@g=yZJvyb0owNV>#MB&|Hue&_kaj*=GaDc`Q%QYT}igwT*Yy zgP*pK;bpGZF%PtR^#oT~I&Slp*E}9h96D>!c&6>#^Zze5)9#NLd!abN*t@QU+glA* zVh#6qeVKM4?m$F*F2Ck-ndIb`c5?Dz17Y(~R|&Lx8(i+0&-k6{v1DIr?j*(rE^k$G z%#{@pRvyBT7KIak$;3(hmS?}I0Y;hkXX+g zW&H|qmhnz51bYvmk9;w<7YdT)dL0#{bW`h{_3;52tzd1A^njU|8K*`evo0fh#Wef4u#a%Ikks@i<=# zi#{@yKR8*WETc)%p51g1wQx*#lvD+V=MKP?`MNcy)V zjgLn(GS~ZiSeA)aQi7G1uKOn%p%TcgKL0opsQQ&V!AWEwApAF8^v4x4!)n*()N#MC z0@R-#=bZxDr#;4&I@^|DzP`WSyJzI%3HqfWN!|Yc1|z3A=*cb!ZC3!b1$q&Vd~Ndg zobI(7bV^BNnB|VvC^nWYo30ptrR2atNf;iQB;U(s6t$+(W zHob~1t8)kdIo(X>b_%D-JT0vTTgpX{S7@Kmg3Dz2t&ItJ89I-B08Qg`ij0IQ&izkA z1fwA#{*?t{p}+?;F|*s>tE;0Q8LRDo5qhV7Aq_WTU%yk}R6@O=yNCt@Oi1pX#Ui%Z zZ5X#1vo-2Y&BZyYl06L{5lcqwpUBLLod-&nve3M>FmBg9SG>VnO%!{RcbwxU=qsk4 z)Rm$6U;=VVQ(b)6$QRT6n-Tk}sL8KT{>8o+c;iQzK4P@CU*2Q|cVl+|6udbQBAYC{ zC!0hZ!Id@Y(RQ+*Gs#eop-f>Cg0fhVGcE-D(e8ERE`$5Tt59IXN&}?*@r(2QAjwV+ zWXxW6lOXiIErX4Ip-qb+BUg)&gcxPVeWImu5p~lP57z@(ISmKb+A@Nh2dhc^PJXxy zN*-r!OPP={TZ3EQhVscV0)h^6O1s+_SIc52r3=pBK!ZBR#IvFs%6l$}n=eezdFJv3`cL_1;@yK}zLJHX z;MwyX%)bMlGwG?x0o;uvvcjQ|^)HgYEIj9Q@#7lYTnP6V6e#!Q!f43d!#-cph(I==DK6@KhM7_yP*+AZHz0`Lt*=(Sf?0IKD zwy3WqeFwq=ytPTq)^t~08>->ur$WboBt!j{?yn(UTVo9B3hEfVrGp36lr-8mp_mf- z^aPvv*E=YCF&Vt$kBAG~Q-&=hr>QY^58lm263_;+6}9~{Y6m0ybz)N67Yp-ua6fqF zR6f=`-w#zh^%2~GaL!iBy{%AcJ#uE8TWHV9np~IjSc%W9O8#OsTQxpdiuNaf!DR=u zpTIqFryPyWO{@_Zb3cp^4j6Bwt&M{px7@{6~%Xznh-)0QJw2*P~T(1F}wp(#CBJ$Qdv zi6p993M$$UMZ;igo93WEsDvkgH>@whc)6$lG?X77HnEBF*D3-fCaHu)TojXb2v-#b zdXd-##e#%HmjbN1f1)ODCxIcD`B|sm7g3yz>4RL@A0Jc>E zx-1H{=6a4$b6Xc0DXXml{j3rn8ODC4V1khQ?xETgPd?hjM?Xu|l7m&$Aw$fB1e?+e z73PyRa6bcC-z`4pjG;>9yUtfM`EIjX;dKpG!)K4IV;U>2dvpHn3gS(q})4xla+OIQfb{TK_6J{+V+jF z-(dCjzW~l2Qiv3HqMB_J_ET6l{V0DFN|eDgCIQW@{kD0M83vwJ-iZ48^0P4uzOFh* z7=CjbYZ5Kv#Q_dB&I3SKbe$}$V{|KE(Thay=^#$TdI;g1I-;vyoA{(D4IigU_HDV%4x%wKROgri?e&9Y%_@2;M5HoYfn zVxcHUI=%=|YHsujJaY2hAHoWYHzC?y|GAFw=}fLfY}O~JV?KMUvZ`R}=?$yUHH(ZA zt6iVp@p1O=?6ngvsvx~F>1F1zJNft&6jFibn}k?VUsNcm6i5QMKG^L+K(#3%NQ z>!Se0qtGq1cUoS43BX1xTRr0I>|78{pIJ__-ptio=2V--N#rltfmi5VKF;IjEYhW2DPNH@qO z(V>@9U3wWRwOvN=%U~?}J{R!-erE~6h}9w^m63Tl&vwiRRTOZiL2C@m>;PHW6Ab#I z<;N?b72J2$l0ka|LR4-f2b`JUY8Bd}j2hSHMs^RN8%bFMH0`J{#XDis{2n@AZqZ=T z`PI>xf9RSJ(EbOk%D_=y1A1-ARDCp4xO0(FW9033N1@?n3w1IRdk{_kPAa`O?rI9O z8x|KkO7HExM#+N4rA5to3Qm71G&Ij>!ZfbUjqR_kLK`Q3T3QdVVQd%_g#A4>i(ofT zI+dpS5SoA|;pck;5(xmjk~6t|yzB^aVEbKl`7@V2LDzG*h;#z*6Sf!=(jDu7 zB>z7~aBGTv7R2o$53cRNn_SQsR(^V8!bxpp&~c1Qd~LGz@HDX8Hn=KgU}u2E-u1@C z6w9VFgS@tmt`3oj#M(IMO_#$m9@|K{SC2|ImoqZ<&~6YjI{#cOQ%)SwE&FHQ3J~RbPn-QO;6!5Iy985o~7RZ z$J`icAhil;D%6_y+IfLD1;*lJ?@r`I{m8w3X!9~Velb)M7y1iW=Bn}6M8J>K)14nH z28TCowr-np^oLDTysM4@rUt;@oiBQS*pQp+K?d?=Pi_1mXVe?Z+6`@1W4QBP5Y+Kg zZ>ha;r9)8s57m{`hvxc96-FIN0weo_P}aiYl(FP zaMtV0m#Zw#5I0zlTCVMg{Z0hH(u{u_n5KoV zMEhQ#f$+4bZ*024wTj&Fv}BM7UlepKfGGHS0;UsZYT$@LpHv6FBcM_8u+dt}Je4Mk zfDnVbtc`L%Ms4n}Ry4GI17ZWu>i}~Rwhqxu7N>f=VS#LV$!a!!UE<~b_fL=~rpe28eRqOivKKv32%-ZuY+^8#F&@Pa8=s5|@Ct`P+;Q{FHbibS8RZg{$ z*Izs|FWpVHF4LVSO zR}~=5KMJSzcTVJNSBppmMUQ|BX^5qq1y)J_Jz*M7+z-}ra5sVU*bYDH>%E;oYJdAV z>cD5^@RblLef;!5DyHeW2y<5Di;)vhX3{y)fv;9k0}Ub83OCSoTQ7wx>~rZ?x_wn> z^~H|gHJ#ETXS5{pC%0oM5=EwW{MKuj-SQ7RxfLRhlr!-oj)x*GHE+EJGIGv1&%OlE z>&#u(n_uWVX_fQpWH~g}3f8!kBs0qe3Q(EPFdhqg*CE+B2&%a)S0q`tFt@|BvQ44a+>zaVBG?`F zfQ#My<-9ao!FjC;tDU-+GmZg$fO*zjHRe572t?CB?QYk^P^#4!K+dSS4_cPj?vLWS zfMH|3%FAa0C~GW|+4<~}>+wXqZa3Bkht7_wD5AL58&`EADlpdy95XL_4Eo&9iq21w ztlPmcYZdy=>-q81q*SS}i-~BLBYig{8zIDXRvvdev<}!BTmk-G?aAPXtvcV11 z@TwCcC%GQNAN1A@1lb%*SI)OtcT%~690}~GgUsFLs@D{;aS4pQB@W`y(&YtHS$R^5 zURaB`#3BV$3n;DBtjmTO2jWo(J)9O9$%b*3(sqk%h`B^@b}6Kyn-<>!%Evf00cO z0YfMOLwIzm@~NoE(O0Br>C zaRP!DpP&kY0aWUF*z}2?DM;VVd)`OOB#RRJ-2&pldYEM4!i$G&Ph*J`RN!I>;wUy& zB>mzvEG)u;HyDL_F6T#F2FXXo?rRW8Y+q#58BN-P5x^EMGC3iIOt#HT*(4-x-?S_! zol-F`3xXn|qH^qB0x3X7wxib6j7qj3@|gJ^!mAKNwi;ri4j4uhk3eYwgsf2uZemB; zjFVtP{`~r#b^VMzUAd2Cc;1log&R*!Pmd^fFBI;y70kiNHLU(=QW|$<-N;7WbrOPuBf^MjjdC`TMO~d> zBIPV{wm`qmKruTOiFO;u+)_;GC&&fCC1tkMF-Ct{)1?_}qEtZKCklrYR>EF?DfF_Z z)bA#I{iHm2=^<=&w^2;_rm~R7zQ+?Lf}1GeKW>wXDfh(kr#W``lbaVITZ%||tv;fZ z9t>j?ibHD!bYsB&?L(@~y-Ikim|vE}uvLSCiO7-|yA47`X5kJoO=vxvSWBIg2Q4(c zi$DPo$o}>65WEKkqhSe&ViCy%S>IfE_e!#H@_2Z={e<@%r=9J@U_Qkx!JNN&SjCJe^Iop;A2`o?JP_qe6Ys z)D#jD1CV#GS1=T~T7{Y0)l3fYb>9GuCjFUoKmx^sBXI@zQz*A$GYmO zV7&1njEX<`4vtx2(x7kB)%wdWTx^Ocj!;nCt8orTJNo^-FxoYVB7p^TJGEJ3{dZ)* z;c;OS({(ZJB*y7YyEnyXQgkoiDY{!v)?SflY$Vfi{f~fnK6$Ts0dsZ^*fe;Y@kTzO zMpVlrAX0B+A5l6%NnUDiuPRJq!CM&a@cjY{*_WYZzVc8jY~c!0SPjE`7>(Zh@)fBX zo^r(A;f1G?%|c#;nl$ElQJd7GZt&)(n=}A&Wz3~23mh^n`zA=SQ0|p% zf`+ye=~uB6-XQh*qhM;kVy~nk05xL1s5B3iP3(>0JmI?Z7OWHO1oN{_AK=a26%AtZ zV0xHxZ_Bb;zWll^PB7l#bOEdudxdF20gU6kz+`-FfPeTYo{uJ+ICNR$|$deBSCESDQo+QD( zOPk}BSkIX%*tFhmRS0voGY<96L#cJ&U?6UE4dol~$pWx0cmWOyW6)pPQcsq!&?m&h zPapy@dZ$x)e5Pmyv5!8B+;tq}oL+ANC3Mw#5Y2`d9cbV{eL3uM!Ix2=+ArTmAO}i35doB+wd3Nfe|(gDnkWRq^~f26QQs(xG*|#%zkY zz84eaHgO0su4%#xAg7#KPfpK5rgfNOuzfB~8xbow)iF_*pu;E>$Kx5A)krivqM z;s~iYq`9rB{&-|`2}YM+jd6GQ!zQKR4WkwhLD3o6euhn!pD$bsU-99q{jdoZRo^h2 zg?6`M`KqN;>{^H(lELOh$$ssv&v!VOCq*Dck7 zet-@X!S=lh#cG_W>Ejo{#!n5y=b%FeQ$6KwU}>F2l9!>lkKBSs`SyQvu*yFko3$Sf zxgPs#7C|k-VteV~`twvwYvr635AqXStB9X>gHmwETQ{ zzXXbf2dP8|Ki_7ntF#J2D>jsffmOd1y1s|jpgI%?e4*M5SA>cj;#?$%6oapYa$tf6 zRw84p0szuR%%+5A6GfQCUfOSc39pla^K|=cgd?q1g2>9d5#JOfhTM#ygcEVtXL-z} zid*niWS_7xN5mf3j6LHzDD)xrUfU5Pj6q1}$|i)G*7|wGcqN;38OCntH$}k3jtBWs zFtfKk4cm?4XJrFnZsL+WA-6D-ZzQZg4yiQO#0y0#Wbz{~*U!&ajz$>wM##oukbt6 zFL%>f(G!lbreSZLQUQ-MGfU@6kpFnGKPyEb^5jvLf zo4;Toc-2F&+irM)V|=IUDwJ9e#sY{`SOVL;z>I}eM?w}qP77Hie*paF& z^|9C+ZgU2bVk6+?EKz2Pbjpo{tjx4_U+7t#f%I4X+Tci8xl1 zgS9Rhbz@v!Rrf_eKXbnD6)blYj%r#4O!2d6McWOU=(oUF9bow(#WOl%}n#)tvu<>vR<+- z+`kT{*(owJt`wVHFu>ZX;20SNATX!HP%9L*piod1w28e$l0gyRWRrHmJ-JYBIwga< zJ|JRqM@l^4I#UK@1I;NIgqz#)1w3FiTh!EklRfCV;-X)}41?9*PIoZ)%w zA|-U7E`V9g7k(mVj@s}d$QdOU(Ojt1FmCLl3-&b1<_rfm{%0;W*wU94T+l);>`@Um z2C{e$h{8A}FQ@E6Uu3%zuDRQxuwlAVm_D^uU%>Aq8VTEE%WQaMPwB()^5yE}NtR#F zck(bX;ACyOuduEF5C4h>BF*unyPHoDg>>1$DHyGZ5b&FYS(m&0T`%?F%TI&Y!}G~} zA~`0dT@a$-Cl(4Xnb39$`4JN@sDiF5*mPotY*|PyVZ2NiI{IIx2Qo)T$XmEEY2;y7 z!PD()NnhY(>YxA^Ww`7#t@ZaTvEZRZ0WZ-ED91E#0|m)lSgq1}jkWIunO`$0WVP_G z)p)n~Mw8`)H&?&%-SWk;FY?7+N4L?y2gh!{ii)}(bhP2AxW0JF>MKphb?4xF&-OWO zJtM!}d5!j^%EQ@5YMlEUFWPEocU?LW>g@VbTS>}pBKn%Am*c(fts}~fM!tP@Wu}4V z0ii{AoV5PzWlrZX+4z_JyZU3;C+^pb`UlWi4}8~?-s-ygstdnc5=#n%F{G%UUv36oY6jMSX3!z>c`9a+xmg868+(&CzGv> z)|WR4D>4EIyvf^=b2o^qZh_aKXWinWc)DMnfW?j~jpDLC$s266E!QQ!S?f#>)7!o#VJTT_O=hjrn}nI{RT+!g8}&bDRS4ZE zui^ymez~3ynrk@H(nMYoKzlFU;pK!|0x6b2lbCC^QKubq7 zd*y(hIQd%yas-)@`5M--f*Y?T3qIC-Z(wBTQnQD8JGmQSTc4#JeR-E#`pB)K~7*G zUC%FIAn?-xaCMqH}J>cWs?13STZM!Tf1(cO7)zf->y z8ezP!K1C&Xw%GC&5gCSIo3Ud8Y>IuW?Z#xy#EvLFVrs z^#qP@7e}QUtmwslgzjdZA~3kO_0R7KWC@3cihM_FQ@mGyFj=spSJz5uy5ZTgLK}nS zt#}gr>p^<|;Ef)@{~-Nv*7u2_BR081F|K^SNx-GT)EtJG9729?0p$;f)hv$jJR9c>ssej__fOF zeU+q)K-)88(A$0o|8cl84|nV#Ogig7c`qn0s>%<1m$9cMV!CZ;TG8e7=qfYm;xi7- zQyKt0Vel}W@4HP2d7Y6!Xkv_-;FmBSXRN;)R=fy91;SFYLr5JDUp@y;dg(=`Zqg%J z6D!5ZnlAS)JOcrK59W-F*ECG7X9c25`(XO<*1upEsLPypLUAf?PJ8*g zL%K8jysiMfeFO(Q`Z#aAXsD%E)}EzEAWrbxMvFt96)KavOS71{V`rWDFX_ou=>t{oiUbv6=hS1a- zk#>eSy|UIxBQJimy~^`}W{y?@XH){wsHt#aFSsz3$Cb7urwNQaTd$CFG5*N#kuq)W z+LLZ`d8C@$^xnI%vSkp>{TuFb<^M@=M@`4LuB_3r>r)%G8Yl7ECa}4LOV8AfRW@0s zC3*iL5%te5{~!(1Xz6J(DkeeZsM?*x4>Kmle%Zaj9d)R zqDZ4W0r7-ks<-ElhH@J{@57n@|Yt{Z1`ueC<;w%HJpj(ZJ;CfbJ4MLiww1kX}WnDB3E^5ng^ch#ubQNq7Q){ zyCo0S0%!jujnngQo##h+^Y^5IRdfo_&RuZbOMpwZ*1%gOr2yflPpbw?ZJ1qwhP!;6 z9h$Gl+1cppjDZW9br9_YAAr7Xz0CQxKSkba-a(t`x9P~o@jpmA8H#oq!e zycF_$!8UXv&Aai4w7&o&HHiF{dtz_%{tb+!)fE-bbjrCL!)2(rJOWdJnr7ofPRcXE z*AkeCi=7KKo3KAT_|NXWzAbyk6)a)G<~obzKtLLpV;S%4?#(|B%}MwAvs_2OJ8k+GF0k6d=?epbG;^> zK2jGx!J2F`cCFbrF1dU)pz{9VRB^DMmdT-eFn~HlA0gKdT~QmH^E!C2n4UC2tkrY& zUdM?+%eP?Q(y575ZCwg1|sve0o%=tYzHD5{rzsp1Rt3GKPZhxNS11=^DI$m>GE0T%}pZ_X2we z{HLR|>SJfEoG0XNOQK2uFCTwhPmEy*CPl-8mDbo0Y=ndE%(G11V4HM}Q_}w(4xy*t zKqwkRO?y_=YiSWPJkGY~Ym`a~+&=kH1Q2ula!eU7$2{;PJy_RP?qvc|=MRtJXMO$m zCb|MP^90uw>v=ak`kp@X=uI>j@8s*<`XV^WUy7sE0(UW%g8d76D(>7%v>}^Vl0rnV z<3$L|m6Jh6FvNw>c}#oqqo%;?_(AkT3?9Ao z=ZrU*nzux;W(?!ULI_>T z_kfn%!>#Ld{QFbBq>DTGwpH_3f?WD;xC<=xL`|XMQgSaPjIdJu}C*B%X|DIx%u+zMX&lG!2ds8eR({TUHJEz z89PO4P_k7bOSIa;Bt>N3mn@Z3$Xd!eo`_Z|TcH`1H8GDRRF-HZF(HwqlwE|eG?;m> zThH(JzMt2B?sMPgoa?&2*Ls#2Vo!W^0`pP8QN)ERuuFaPAN;@35g`qm!~ewOiHii1 zKEO%nE;uNlaB7hqH2YtG`>XNM^8YW?xCCJFraj@mZhf!nka7WWKxm{bA-u3)?E7(K zhMQc`GxP^XdLaU5AqPL{TdYqAUgTe~Y73v6i?NU{EfU4014DDk7(;b0JOeMWP@~4G znJ=1E3}IX=>SK4pp^K}CI|veUeR)T0Yw4OM}HqTDLtq_4h^wWIMTX8jD>8#(PyXZ*jLs|is*UAzH8Ee+dBMIKWU&T%*>}}t>0jt%YJW{XbP)Ev ztUJsE@;6|!dBo&NhrPXzb=d8WJEh$Ay*LEY$DX^CfLF(P~OzPW);XnrihqkQoB=#mZN!(`K-nQ1a?2rdJ|{XV9M4s|#&d0>*jo9;Sd zh#DO1$Y%*aw@2X9S6CzFiy3`aT81Lk%gO{hxNgSmf!NLv`dhYQ)ag>1DCvcI6!saI zd@H{fX8GNt{g1|$mS=%QWm($nE@*a{fnw7|;6+7ycU@8-CX)gaOw|7) zlM3uZ9sf;?KT=P(`txHlfxV*d8!;-s1yy|Zb)-a|1JYZ+cafkk%;A$0iBma6$i*(2w_+~ zchFrsNEqJ($IQNfV#OV#Ukcv>OGp>a2v`E4yimwUh3}NG@2JC7_uqkd=St=80Prq+ zuR0ClT?5w1{A5mjSww5$?85>;>wof)R|X_`SSMOf3c_0Y>}J02UA6CH<_n)fjUM!h z)VYzjgDEk8BF+LPAz8R;Uq23Sz7;sp(BGrf_(l|Vf{Ypbg#Kbhdt24|z9LmWXyp2Z zVfNFISvyGH3fSQwC`R57rbJc#U_2ueNQPC(d`uMf7n;G6)RKWLvFPKO&FtKEIav_k zDNy+jm$<2YKog^N1T@~9qMHXIT@4Hb!$kGP8tVV5hM*dV=?)B2-~j;<*6^vj&gRsL zH0r0pxV-iv0K%xASW^RLKECkPSH*Y{-qy{p{+pSeRSda@sfFp-Ol%S`G+VSf_5*x^ z;W)^cUjIUu@rUwnJ}m_12GFPVfpDLJA**$4is>Eb*J1y#UHdEtL+nEDPLR`fU^q=g zuI>$+77#gnqea9|odY)rCH6DG9@`(@pcq|c5iL@{?h!wqZw`&CYmLAU+h3`KttEg+ zvFq?uKUI*bjL0{kVYuy<-xH`9%SVCnQVR2h02H29qy(2Ji9^Y4pyY&3?S%ywPO=to z*-s8L%E&MAQomnjGp z0c>`E;8q{pfu~^*`2JTHYM2|IgcUE`VQN|*1>vZ|8yUg9_v)@N)X>dtJFJa_V>GT7ou;65mMd;CZbnMpc^Cda} z@Qpj9khBR@x)|v^;jCEg#VyL=6ZuGZUE!*Y{JQ+eFb>iji4_pTUeV0*WCYE(8IsJN z@MqQ-LN4>4gCJi3nfC7jGa)zor#0$p5$pM#p3ArZ6=42fPeFbMQ|Px~e3NV#G`l%6 zle0FnATo&aTKj2xQa>ohzcG}NoV7uOR4vF7YH6$>2Yao=POh>jJ|n ziB(UVJ!j+u!T!nG`ZFmp4mc~3dr4qUS8rb`0N7L807wJYjL#F87umPj*1hb&vNP|y zd~iem<4qb41K+j>^4t0JjL%S}yzOrsQdHUN{@m_QWZxPVo2KyVWcXG9*D(-6CCt9z zhkmlhTK*2^%dUc1?Vm=Z&r0Ak)!cu6Z9gB(6j7hM0j~CB)8V<+~j((u1pVj3YDL z$w5aklh*~iN{*6QfMG_*bBE*`DzHWHbNs~E;8Gm@kYcc`^iff;iZ6bb{2px9M@)SP z9i&!30XAJGoi#Z&?fPUD`95Y+fZrvy8>sWZqbj=-vgmYADD@5aa+841Y57XvEK+dT{@koLt&|O z(N7KB=~Y2Oaxkq|o(XbOg&Z7zJKXf0BVr!8PU+MmoU|!6MdS5_Kdc~=T7wP0#mJ9Y z^A0FliR$|;lJCsZRpW$ETpWzGspZtb@q_H}D=v9^;lu0jsNei@FFR{48tQ6s(eS@f zVRR1r8e9~-l0ukAcxWxVo&a+7^`TK#WTIPTthUPWVbWuOiXkwB+I`h(&hd(CCf2iE)psh_8xWzDqy|83F7O z!J7U*76*~8z%1Qp`C*oD4?kHLV%S>pZpS~#SAA~>i*xay0gM+fx0$lNh^}mfk7|f4 z57Tf%bt92{m!`ApI{1(PRIL%$=9pHtQrHbEjPwMsvq<2O5nU~8V&aD=v-D1FdA28(!Vq8h5nTi8mQy}76gb{A0Clz)T z=*D=1!yv?d6!yQoSqUVlf^{S8vtCbD!ECTxWBC?yg#AGKhOLh6O;|lXjm78jF{_GN z*=D%_3mm2_lN!r4N$zB%ia>4Sf$jpBl)AA)B#DIhnFk9gIc_e9J7D{^< zBEaPo#ht^Gb+Gx&n`kXhez|!_k#hZK8J>jM(Ux%2GP^f4eKbsfUV|(iCW)qXT6jI% z1U8O?4EIVwmQmSt_8bQOcx)EN(BYM*yl5DMSWvvl_~?zjj+MYwYAn_rr~B>Y)n1ib zyWrK82NEIdj%?B{P```GXr=(AwIyhAXv zPrJ+yZjx&808ZJ9=dgP)u1EqCYZXBB;h&4f&SQXCdE6ez?9+lng3mD3c?|081SYG^ z+Wt7q7VBO!yM5mF2@fuRi4#w%!DbLs_r4TS=L>eApj+^b?Sz|pW_I~`fa0S)T;vIC z7(VsoNi9qfQZUGRWJ8zjD~hCrb-A-JXG|CDVGC9P88!#5iPJ5{+Jhap|INKxG;-LN zu$TZdQF|}|ai_Bc)4{;lW6}$H!xcoEgW>EOpxW#u3$%su1k)#S8s!LF4^3SQV=nw;M7BBV9|q$QM1l^~ zG?wE#?jKT=GKM$4xxg4(ilCEpmI3CGD!5?|@-jtO0kQiM4Dcuh7x3K5HI`3oRp3Tg zZUe?8XjZY?WiTFL|0w0KlS;l~pYDQhwgnz@)*jUOYb^~e)wlHyjo5t*0#!N;!?|I} zmu1#`&AtC+%SQO!F{at6V3E?DYT@M&3NFI{_wlFtI2JBkS1H zZ&5H9^;T2%oh=NUy;kUrt3vR2zX1v!8m-y=3%udKZK6208{>Nv^pLrPPB+M3eOanZ z=lxCrAvd4@9#?O}O?()M?1Bk-9WSz2a}8JPJj!o@V+H^mF4t--n|(c%iwCrf@98h6 z4Y>-SkB_Q15Y1bE@X&x~CqOrC<;Hgi&~NnB-G@t=%M1rmGEWZs`4isPizM^aG=(`a z;wF?U(w7cGXEs{`(Csxsuk$$wpzS%;Lc|?+nLyE*(rMltcK69=^oFAzoV1;DV(ot0 zcM+%oVJ(~s(XB3L?HDvu2O^pH@7vtVPI#p7fZh;J!`&7QshZEKUae%T%F!YddTkVX z{g?&4_NT2QP3i_e-CPNiI1&V0NlZO!a8-tuU)oGUnL)LS9N#9!w<(3o#NjTulix^>D9pCE+$$u2!7_n$bU-$xfXItW){OXTHJ@a~MVoDi$O|*u)bW+w=KhK=Ko$ zp2EzxP857i%FLlm~^z<})u8vHcO`|D343^eeYGwO=6M_>-mk9JfX11WehA zr$$`ZbL&x#m23J{&5ea`@wtiS`4#n69yc&s`bcSC(YEOad$71ijv&8(0$>?GSaw_kxkr;`t0kUvs#T8qkod{rZL*M_Rkh@`DyCq z2OsFQg3=16?2$XC`Bz!nd?5@kn$_pb%_*Qyw&?-#=QxfXJAO!ap@+xXa$#J>(ClrM zBC1Oum*f>$>qUUDbj%4^q7B79ABJNs2!T=gyp%tY-(GJ0G5(8yO0=xei7s?|=C}+J zin_!kj%;>cYrg(_2`gTk{z&P0PK_0!u9DW)L)6lA)-DuU_kow3+qop?)u~;@hmyfk zcxGYNk!JbU~SE=qVh@XCHLa&$h*h!3lExA+`` z5|G13t~)aNIGH9@u}Mh&nqSC{(I{2)P~itzET(yyiq zb;NHi;As*3zNLN}mxt;T+12t7`%5m$|6$+c*7xhsUV{3x4^pxT6Jk>i)(u}0YT;Ih zm%rAp@6*tV8a6SQqBU~t99sIGU?ssrd5g+-(Idb>@9f@OaZ`ejOB#@}w-cRMzB)Yzl_bccGic+fTdstjQ zi_=3VdbD^^Db7#rd3^$DKWZVm_cJ9QALixFCRaNZPq97Q{k!Y6=O+%#%*@O$Ic<2d zRD9)j(5bzQUmv@Dm|JQyz%%&Ii&`BMWG-_t98=5p5OwUde&wtUgp$tvz%I=^^sW>~ z!#~Va8gZVIsirrqGQn$#n6iCX+jhB`@d3i3w(M}%})G^XRu{@Hy zul4NbW(Qa4L@gJi2${H-c3n-+)*r6D8e>#`<@ehgYC*;Z1ME&d1+>!!1<@z6bjS5# zYVVqlS}#>@WT_>HC?9y$PnCH;#b2%&#E4pNdwNQHuL$dix_Op?W?`|*VdN&iw#xFk zHtaDK{%V!ppXYY1*In%@SExr`$it&!9nX<8<5>n=K8nma&2g*C$*CDGBm3?p z*`gB>keDfbktk$rvix%>c}y{nZOG?qo;jDEopCmFm$I zY&H~oe|F+PBqE7Zy&Hc_ReY}had9%}r@QYOMD_-^ZZx^|rBNE0=wSVJg?5TD%Xs#V=J*R)O~@8>|X0$AgVVq|rm-Pr`Q29?XUR zNb8%DHX$A>D;tPfa`E8ak@+Em`rc5JXBM~wh%hrp6pmLu z7e>)r`t{XV+Y$NQdM+{DSSQlm^x-!nk)L0K<|L8fSNk|%>|k&35{U;p=DLpZBfZMD zTW6QUAyrqnjN`-gLslt&X|sD#53#2Jz3YaUnMOiFy7ee(|5(FMcGj~EaMwJcI7goODBI2^gnL=Cb-sPvu0Dx%3A zp1>D9rD@R9{5X{vrY_kA8B_5T;I5vhD=xV)2=Gx6*`2Z}0IL(=DT1Yi3e3RP_J z)L!hNmtb-c)m1W^AD!ezakW?~^qbmGH1g}=L5#)`TFS%~7XX;0FG7@91(G;wQPEz` zptmAv0aK07jgbUI{RxqsSUI4_)I+g_`)!>h{2t0tTtbXEj$wjB+tTJ34|7(b;jGGt z?(B{k0I|t7IKp8$SYrMYN?rct&N%HLnrH{Br%;NBuIZ{K!B=@3d`hPeo9Tw+BeILS z#M}q%jua%F5Jo`TW*p=rU_nQeIk%YU3s^kq_sEqk37aWbL@6li;x6{>Ie@su#vnv{ z!8b3$7Om6WeJzvAbMcQa9HWeLCr<(HgBm+&1`}!3TqtT9*kXexzk|h>^&p?^Ba6Ki zlPh77O}Fot$w*Pi`>(o_K@TNN!XX*a5}A7wz9){4tkmtB3vs&PEhHTQdR3fNTGxWkgUE@4olX)#neQP_x7wb=fbbpLD;)(*V=6nKwkUsSR4E6s;1^< zK}OylsD#baLa7k^QfpvYQ(U}_SEvWV-`BAV63J_aTSlO0tMTsj27At0;nwdd(y$1F z$@*d!9qJ3iZO-evzHv(;DEtKu0_J%?aS7F|=XM~)X4m%yQya{2v5l2^d%BIW`r0^BihA+)xs?`yB z-M&*|ZruW9Tocq&YwpP(fHWU4tK_oklq5v=!j$lSD7m<4WDPI6=3C=PbMN<&kIP=z z1|dEZt)d>}qE%UBj zFAF^ik^M$K`z{6tqC)NBIz<5)ezpEmpKO~)AdjEqF?W7kT*ILPJO5KKjV9Eb1cfqe zW6^?@%RX_VPPscaW{KajpuAwTL%^H|(_a~Fxw`vhj0ZfZI8COu6vB;MbsTA_6i0G2 zjjlSg?(*Q!(8CbT8@4VC>v8#)aEN3$W=hrkrXk374SsxPi8}r9Y1-_S)arMq6i{+% zkhU~3cQ53?=9XJs;_ka4X;=aJ&KpfuYBbG*d_^I*;?S9b4;_a7zWKJ9pG5Yucd+1g zFT6BAzqUlgemFsWxM|P3BsVc>Ypan-W=)#>3T=K{6P=-8NzSKZAfQ-f1`O7{kIchf zI7J{I0gGha0s0<)hPJMpFXz&(WKM<6%%(_@P?P|fEqTdmsPX$@)%kE`66dXV) zYb~d(GJRJgDxc(T0oAiWVwg@(22+#tN}=RWb3UNUP!@`6`&%W?Frf4_mESg~``Gfo z?qC*`U?jRdt>P>Ppu=|j-#Ij%0&fwLO%IfnewB4Qja>l8e$IRG_&w!-yW8EA0f%Pd z=+zHz8#T5=(q+h{=ndu3FUeXB^P`Yd@9o7Ldf^3NuD|TJ`(VP2&ic=|c7A`HRDkT) zNv0b?o^T$t(2R%@od%$fc14s7Sy+GUMD_HGO|Z_>1k%{iNGykfc)t1}kiUH9+FvKN zuTaZA5s`5WcgTZ%MLdXl{IzQBwnn0MJ<1Hb-MQqs4Uj7*Q_B*50nR>Yr+_M$0IKs` zPU)X!OMX;rHgRnEsuvzdem8!$G{6U~;jenFVCnw*YSlNpx?a_7?^l3dog=`_fi^wj zlLk}ZMiYr$&M=zN*s>m-P&+{0CBvb9^q&+syTx0M)8Fo`xl!+>TYBNN1UtiUFM6l9 z-uAhJiXNt08n98;5(?5Xq$L4DG)v2Nd^ta&sjq5_1Z13QV+S>QTmnI)Jf{e8eb<{a zTGU?8clFYSjPE09h@p24f;n3A1+2_kN$VGNyg!eOj-hb#@n2i(TvRUJg}afB2l1&N z`B38BI|U08rr|j1Thr(WcAyAzEf)8ATvX2b1gkynMqbLZWl&7bkv}bAT$QrtJRn4X zqZ>m=3Q<=oCxfYC##t*7t@a@rQd_cce&rgfFaV|Joa1|TPaYbE8lI@EW8b!Aeokb$ z^Xu8qZ}&}A>~Z|#LCk3(!otY2&-X&jaV=554~6uSNpKzkbM~JISD=XL?sHYUYi2Jy zV+{&A^`2aN+~S2BlIHAMu^6_K_F|0SHyh3$;;&VRF0C^33f7vP1f}VUUU>%7ve(#5_laV#u=)7ygJpCt~^(c@_0TU6Z>+X>mVrw=tD} zfTiH^jKoPwiB|iWDiZ~D){UjZ^peUbGsQXE<9pdX5AjrPDA#VXCX5$$i*lnxsFdD< znD^M75|!C=Wb=v0mLT?o6|RNwTD%OrxEkkpdow3uTu~q|XbfyQSCX0azCA+w0iG(T zXV&390|1b`=cP90q~zBPar*Vg*@w;PLy;A*d^-C()R>bb6|FXHUje7Sr(IRym#oTE z6H_ZX(XHE+G=mK#e%&|(!0_de4AN^BS)w^b;tW;{UGWn-nY^)!e70?rh|re z)=x;`R?67+r}$Nep^BM3H2d*QlPXDJLSKGftuzR6Ai~o}o4!#WiK5|uK-rEel3vyA zr%PG7aw~XjFD+M}l2XNy4)Xv=w%_GnF4Z|jpjefFHRV@c+RzV>9<%}D-V2xbI`xKK;@$eJhr<3zy*vuw6uqDB#Xhwecnr}<`gw>q!W4? zUrO(*qHmJmM$+BMiQOZ|eiVJs-Hs<4#<=oab0<4My({2N#=F&$?_^CySpPh`2W5@DW}D z{5S`CoJntJ&RclYETXo&3l`|!^xMTEYMmbZl209~*$vCKq2c6sph&cy8kxY%9y6#HPWR2B?-UtK(edCi2q-q<-D zPv(em-E=3P0K4)o;|>+l8?KpjRl`er78eqAy_qUbpGtX<{R;~!4?*NHhcyo(m2Ctt zacj3;h=C0R=}b-9G0*yw>?2=(^W)dbp>I}@Ila1V?xvRnYi2&MnaX#H=Lg`&b7PyI zXo>s7$dmr9lhKr~P&t3+lUZxOQ1W#YN7_9HTeE&h+P&EDKE#{{quhSD!Y4- 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); +} From 119a0463ed766815f49278a261604c3deb02b81c Mon Sep 17 00:00:00 2001 From: Emiliia Nesterovych Date: Wed, 3 Aug 2022 13:17:41 +0200 Subject: [PATCH 2/3] extend kafka dev ui --- .../client/runtime/KafkaAdminClient.java | 22 +- .../client/runtime/ui/KafkaTopicClient.java | 236 ++++++++- .../client/runtime/ui/KafkaUiHandler.java | 24 + .../kafka/client/runtime/ui/KafkaUiUtils.java | 113 ++++- .../kafka/client/runtime/ui/model/Order.java | 6 + .../model/converter/KafkaModelConverter.java | 20 + .../request/KafkaMessageCreateRequest.java | 39 ++ .../model/request/KafkaMessagesRequest.java | 51 ++ .../ui/model/request/KafkaOffsetRequest.java | 32 ++ .../ui/model/response/KafkaConsumerGroup.java | 56 +++ .../response/KafkaConsumerGroupMember.java | 38 ++ ...onsumerGroupMemberPartitionAssignment.java | 29 ++ .../runtime/ui/model/response/KafkaInfo.java | 8 +- .../ui/model/response/KafkaMessage.java | 43 ++ .../ui/model/response/KafkaMessagePage.java | 22 + .../runtime/ui/model/response/KafkaTopic.java | 8 +- .../runtime/ui/util/ConsumerFactory.java | 37 ++ .../kafka-client/ui/src/main/webapp/config.js | 4 + .../ui/src/main/webapp/index.html | 354 ++++++++++++-- .../ui/src/main/webapp/kafka_ui.js | 11 + .../webapp/pages/consumerGroupDetailsPage.js | 86 ++++ .../main/webapp/pages/consumerGroupPage.js | 48 ++ .../ui/src/main/webapp/pages/messagesPage.js | 449 ++++++++++++++++++ .../ui/src/main/webapp/pages/navigator.js | 175 +++++++ .../ui/src/main/webapp/pages/topicsPage.js | 188 ++++++++ .../src/main/webapp/util/contentManagement.js | 46 ++ .../ui/src/main/webapp/util/datetimeUtil.js | 17 + .../ui/src/main/webapp/util/logo.js | 8 + .../ui/src/main/webapp/util/spinner.js | 21 + 29 files changed, 2139 insertions(+), 52 deletions(-) create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/Order.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/converter/KafkaModelConverter.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessageCreateRequest.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessagesRequest.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaOffsetRequest.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroup.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMember.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMemberPartitionAssignment.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessage.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessagePage.java create mode 100644 extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/util/ConsumerFactory.java create mode 100644 extensions/kafka-client/ui/src/main/webapp/config.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/kafka_ui.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/messagesPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/navigator.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/pages/topicsPage.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/util/datetimeUtil.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/util/logo.js create mode 100644 extensions/kafka-client/ui/src/main/webapp/util/spinner.js 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 index 78f68171bab4f..c9b75dc1d00c0 100644 --- 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 @@ -1,12 +1,8 @@ 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 java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; @@ -52,6 +48,14 @@ public Collection getTopics() throws InterruptedException, Executi return client.listTopics().listings().get(); } + public Collection getConsumerGroups() throws InterruptedException, ExecutionException { + var consumerGroupIds = client.listConsumerGroups().all().get().stream() + .map(ConsumerGroupListing::groupId) + .collect(Collectors.toList()); + return client.describeConsumerGroups(consumerGroupIds).all().get() + .values(); + } + public boolean deleteTopic(String name) { Collection topics = new ArrayList<>(); topics.add(name); @@ -68,6 +72,10 @@ public boolean createTopic(KafkaCreateTopicRequest kafkaCreateTopicRq) { return ctr.values() != null; } + public ListConsumerGroupOffsetsResult listConsumerGroupOffsets(String groupId) { + return client.listConsumerGroupOffsets(groupId); + } + public Collection getAclInfo() throws InterruptedException, ExecutionException { AclBindingFilter filter = new AclBindingFilter(ResourcePatternFilter.ANY, AccessControlEntryFilter.ANY); var options = new DescribeAclsOptions().timeoutMs(1_000); 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 index a02d760185733..174ef04aa08b8 100644 --- 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 @@ -1,26 +1,48 @@ package io.quarkus.kafka.client.runtime.ui; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import static io.quarkus.kafka.client.runtime.ui.util.ConsumerFactory.createConsumer; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.*; import java.util.concurrent.ExecutionException; +import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; 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.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.TopicPartitionInfo; +import org.apache.kafka.common.serialization.BytesSerializer; +import org.apache.kafka.common.utils.Bytes; +import io.quarkus.kafka.client.runtime.ui.model.Order; +import io.quarkus.kafka.client.runtime.ui.model.converter.KafkaModelConverter; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaMessageCreateRequest; +import io.quarkus.kafka.client.runtime.ui.model.response.KafkaMessagePage; import io.smallrye.common.annotation.Identifier; -@Singleton +@ApplicationScoped public class KafkaTopicClient { + // TODO: make configurable + private static final int RETRIES = 3; + //TODO: inject me private AdminClient adminClient; + KafkaModelConverter modelConverter = new KafkaModelConverter(); + @Inject @Identifier("default-kafka-broker") Map config; @@ -32,6 +54,210 @@ void init() { adminClient = AdminClient.create(conf); } + private Producer createProducer() { + Map config = new HashMap<>(this.config); + + config.put(ProducerConfig.CLIENT_ID_CONFIG, "kafka-ui-producer-" + UUID.randomUUID()); + // TODO: make generic to support AVRO serializer + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, BytesSerializer.class.getName()); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, BytesSerializer.class.getName()); + + return new KafkaProducer<>(config); + } + + /** + * Reads the messages from particular topic. Offset for next page is returned within response. + * The first/last page offset could be retrieved with + * {@link KafkaTopicClient#getPagePartitionOffset(String, Collection, Order)} + * method. + * + * @param topicName topic to read messages from + * @param order ascending or descending. Defaults to descending (newest first) + * @param partitionOffsets the offset for page to be read + * @param pageSize size of read page + * @return page of messages, matching requested filters + */ + public KafkaMessagePage getTopicMessages( + String topicName, + Order order, + Map partitionOffsets, + int pageSize) + throws ExecutionException, InterruptedException { + assertParamsValid(pageSize, partitionOffsets); + + var requestedPartitions = partitionOffsets.keySet(); + assertRequestedPartitionsExist(topicName, requestedPartitions); + if (order == null) + order = Order.OLD_FIRST; + + var allPartitionsResult = getConsumerRecords(topicName, order, pageSize, requestedPartitions, partitionOffsets, + pageSize); + + Comparator> comparator = Comparator.comparing(ConsumerRecord::timestamp); + if (Order.NEW_FIRST == order) + comparator = comparator.reversed(); + allPartitionsResult.sort(comparator); + + // We might have too many values. Throw away newer items, which don't fit into page. + if (allPartitionsResult.size() > pageSize) { + allPartitionsResult = allPartitionsResult.subList(0, pageSize); + } + + var newOffsets = calculateNewPartitionOffset(partitionOffsets, allPartitionsResult, order, topicName); + var convertedResult = allPartitionsResult.stream() + .map(modelConverter::convert) + .collect(Collectors.toList()); + return new KafkaMessagePage(newOffsets, convertedResult); + } + + // Fail fast on wrong params, even before querying Kafka. + private void assertParamsValid(int pageSize, Map partitionOffsets) { + if (pageSize <= 0) + throw new IllegalArgumentException("Page size must be > 0."); + + if (partitionOffsets == null || partitionOffsets.isEmpty()) + throw new IllegalArgumentException("Partition offset map must be specified."); + + for (var partitionOffset : partitionOffsets.entrySet()) { + if (partitionOffset.getValue() < 0) + throw new IllegalArgumentException( + "Partition offset must be > 0."); + } + } + + private ConsumerRecords pollWhenReady(Consumer consumer) { + var attempts = 0; + var pullDuration = Duration.of(100, ChronoUnit.MILLIS); + var result = consumer.poll(pullDuration); + + while (result.isEmpty() && attempts < RETRIES) { + result = consumer.poll(pullDuration); + attempts++; + } + return result; + } + + /* + * FIXME: should consider compaction strategy, when our new offset not necessary = old + total records read, but some + * records might be deleted, so we'll end up seeing duplicates on some pages. + * Imagine this case: + * - page size = 10 + * - 30 messages pushed, value is incremental 1 ... 30. + * - message 10 gets removed, as message 15 has same key because of compaction + * - we request page 1. it had offset 0. we return values [1, 2, 3, ..., 9, 11], total of 10. We get new offset for page 2 = + * 0 + totalRecords = 10. + * - we request page 2. we read starting from offset = 10. There is no message with that offset, but we see message 11 again + * instead. + */ + private Map calculateNewPartitionOffset(Map oldPartitionOffset, + Collection> records, Order order, String topicName) { + var newOffsets = records.stream().map(ConsumerRecord::partition) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + + var newPartitionOffset = new HashMap(); + for (var partition : oldPartitionOffset.keySet()) { + // We should add in case we seek for oldest and reduce for newest. + var multiplier = Order.OLD_FIRST == order ? 1 : -1; + + // If new offset for partition is not there in the map - we didn't have records for that partition. So, just take the old offset. + var newOffset = oldPartitionOffset.get(partition) + multiplier * newOffsets.getOrDefault(partition, 0L); + newPartitionOffset.put(partition, newOffset); + } + return newPartitionOffset; + } + + private long getPosition(String topicName, int partition, Order order) { + try (var consumer = createConsumer(topicName, partition, this.config)) { + var topicPartition = new TopicPartition(topicName, partition); + if (Order.NEW_FIRST == order) { + consumer.seekToEnd(List.of(topicPartition)); + } else { + consumer.seekToBeginning(List.of(topicPartition)); + } + return consumer.position(topicPartition); + } + } + + public Map getPagePartitionOffset(String topicName, Collection requestedPartitions, Order order) + throws ExecutionException, InterruptedException { + assertRequestedPartitionsExist(topicName, requestedPartitions); + + var result = new HashMap(); + for (var requestedPartition : requestedPartitions) { + var maxPosition = getPosition(topicName, requestedPartition, order); + result.put(requestedPartition, maxPosition); + } + + return result; + } + + private List> getConsumerRecords(String topicName, Order order, int pageSize, + Collection requestedPartitions, Map start, int totalMessages) { + List> allPartitionsResult = new ArrayList<>(); + + // Requesting a full page from each partition and then filtering out redundant data. Thus, we'll ensure, we read data in historical order. + for (var requestedPartition : requestedPartitions) { + List> partitionResult = new ArrayList<>(); + var offset = start.get(requestedPartition); + try (var consumer = createConsumer(topicName, requestedPartition, this.config)) { + // Move pointer to currently read position. It might be different per partition, so requesting with offset per partition. + var partition = new TopicPartition(topicName, requestedPartition); + + var seekedOffset = Order.OLD_FIRST == order ? offset : Long.max(offset - pageSize, 0); + consumer.seek(partition, seekedOffset); + + var numberOfMessagesReadSoFar = 0; + var keepOnReading = true; + + while (keepOnReading) { + var records = pollWhenReady(consumer); + if (records.isEmpty()) + keepOnReading = false; + + for (var record : records) { + numberOfMessagesReadSoFar++; + partitionResult.add(record); + + if (numberOfMessagesReadSoFar >= totalMessages) { + keepOnReading = false; + break; + } + } + } + // We need to cut off result, if it was reset to 0, as we don't want see entries from old pages. + if (Order.NEW_FIRST == order && seekedOffset == 0 && partitionResult.size() > offset.intValue()) { + partitionResult.sort(Comparator.comparing(ConsumerRecord::timestamp)); + partitionResult = partitionResult.subList(0, offset.intValue()); + } + + } + allPartitionsResult.addAll(partitionResult); + } + return allPartitionsResult; + } + + private void assertRequestedPartitionsExist(String topicName, Collection requestedPartitions) + throws InterruptedException, ExecutionException { + var topicPartitions = partitions(topicName); + + if (!new HashSet<>(topicPartitions).containsAll(requestedPartitions)) { + throw new IllegalArgumentException(String.format( + "Requested messages from partition, that do not exist. Requested partitions: %s. Existing partitions: %s", + requestedPartitions, topicPartitions)); + } + } + + public void createMessage(KafkaMessageCreateRequest request) { + var record = new ProducerRecord<>(request.getTopic(), request.getPartition(), Bytes.wrap(request.getKey().getBytes()), + Bytes.wrap(request.getValue().getBytes()) + //TODO: support headers + ); + + try (var producer = createProducer()) { + producer.send(record); + } + } + public List partitions(String topicName) throws ExecutionException, InterruptedException { return adminClient.describeTopics(List.of(topicName)) .allTopicNames() 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 index 54f18f49a61ab..99d5b9d550813 100644 --- 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 @@ -10,6 +10,9 @@ 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.kafka.client.runtime.ui.model.request.KafkaMessageCreateRequest; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaMessagesRequest; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaOffsetRequest; import io.quarkus.security.identity.CurrentIdentityAssociation; import io.quarkus.vertx.http.runtime.CurrentVertxRequest; import io.vertx.core.http.HttpServerRequest; @@ -66,6 +69,27 @@ public void handlePost(RoutingContext event) { message = webUtils.toJson(webUtils.getTopics()); res = true; break; + case "topicMessages": + var msgRequest = event.body().asPojo(KafkaMessagesRequest.class); + message = webUtils.toJson(webUtils.getMessages(msgRequest)); + res = true; + break; + case "getOffset": + var request = event.body().asPojo(KafkaOffsetRequest.class); + message = webUtils.toJson(webUtils.getOffset(request)); + res = true; + break; + case "createMessage": + var rq = event.body().asPojo(KafkaMessageCreateRequest.class); + webUtils.createMessage(rq); + message = "{}"; + res = true; + break; + case "getPartitions": + var topicName = body.getString("topicName"); + message = webUtils.toJson(webUtils.partitions(topicName)); + res = true; + break; default: break; } 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 index 31ac51173e07e..862fdcfeb2d2d 100644 --- 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 @@ -1,24 +1,31 @@ package io.quarkus.kafka.client.runtime.ui; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; +import static io.quarkus.kafka.client.runtime.ui.util.ConsumerFactory.createConsumer; + +import java.util.*; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; import javax.inject.Singleton; +import org.apache.kafka.clients.admin.ConsumerGroupDescription; import org.apache.kafka.clients.admin.DescribeClusterResult; +import org.apache.kafka.clients.admin.MemberDescription; import org.apache.kafka.clients.admin.TopicListing; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; import org.apache.kafka.common.Node; +import org.apache.kafka.common.TopicPartition; 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; +import io.quarkus.kafka.client.runtime.ui.model.Order; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaMessageCreateRequest; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaMessagesRequest; +import io.quarkus.kafka.client.runtime.ui.model.request.KafkaOffsetRequest; +import io.quarkus.kafka.client.runtime.ui.model.response.*; +import io.smallrye.common.annotation.Identifier; @Singleton public class KafkaUiUtils { @@ -26,20 +33,24 @@ public class KafkaUiUtils { private final KafkaAdminClient kafkaAdminClient; private final KafkaTopicClient kafkaTopicClient; - private final ObjectMapper objectMapper; - public KafkaUiUtils(KafkaAdminClient kafkaAdminClient, KafkaTopicClient kafkaTopicClient, ObjectMapper objectMapper) { + private final Map config; + + public KafkaUiUtils(KafkaAdminClient kafkaAdminClient, KafkaTopicClient kafkaTopicClient, ObjectMapper objectMapper, + @Identifier("default-kafka-broker") Map config) { this.kafkaAdminClient = kafkaAdminClient; this.kafkaTopicClient = kafkaTopicClient; this.objectMapper = objectMapper; + this.config = config; } public KafkaInfo getKafkaInfo() throws ExecutionException, InterruptedException { var clusterInfo = getClusterInfo(); var broker = clusterInfo.getController().asFullNodeName(); var topics = getTopics(); - return new KafkaInfo(broker, clusterInfo, topics); + var consumerGroups = getConsumerGroups(); + return new KafkaInfo(broker, clusterInfo, topics, consumerGroups); } public KafkaClusterInfo getClusterInfo() throws ExecutionException, InterruptedException { @@ -91,13 +102,93 @@ private KafkaTopic kafkaTopic(TopicListing tl) throws ExecutionException, Interr tl.name(), tl.topicId().toString(), partitions.size(), - tl.isInternal()); + tl.isInternal(), + getTopicMessageCount(tl.name(), partitions)); + } + + public long getTopicMessageCount(String topicName, Collection partitions) + throws ExecutionException, InterruptedException { + var maxPartitionOffsetMap = kafkaTopicClient.getPagePartitionOffset(topicName, partitions, Order.NEW_FIRST); + return maxPartitionOffsetMap.values().stream() + .reduce(Long::sum) + .orElse(0L); } public Collection partitions(String topicName) throws ExecutionException, InterruptedException { return kafkaTopicClient.partitions(topicName); } + public KafkaMessagePage getMessages(KafkaMessagesRequest request) throws ExecutionException, InterruptedException { + return kafkaTopicClient.getTopicMessages(request.getTopicName(), request.getOrder(), request.getPartitionOffset(), + request.getPageSize()); + } + + public void createMessage(KafkaMessageCreateRequest request) { + kafkaTopicClient.createMessage(request); + } + + public List getConsumerGroups() throws InterruptedException, ExecutionException { + List res = new ArrayList<>(); + for (ConsumerGroupDescription cgd : kafkaAdminClient.getConsumerGroups()) { + + var metadata = kafkaAdminClient.listConsumerGroupOffsets(cgd.groupId()) + .partitionsToOffsetAndMetadata().get(); + var members = cgd.members().stream() + .map(member -> new KafkaConsumerGroupMember( + member.consumerId(), + member.clientId(), + member.host(), + getPartitionAssignments(metadata, member))) + .collect(Collectors.toSet()); + + res.add(new KafkaConsumerGroup( + cgd.groupId(), + cgd.state().name(), + cgd.coordinator().host(), + cgd.coordinator().id(), + cgd.partitionAssignor(), + getTotalLag(members), + members)); + } + return res; + } + + private long getTotalLag(Set members) { + return members.stream() + .map(KafkaConsumerGroupMember::getPartitions) + .flatMap(Collection::stream) + .map(KafkaConsumerGroupMemberPartitionAssignment::getLag) + .reduce(Long::sum) + .orElse(0L); + } + + private Set getPartitionAssignments( + Map topicOffsetMap, MemberDescription member) { + var topicPartitions = member.assignment().topicPartitions(); + try (var consumer = createConsumer(topicPartitions, config)) { + var endOffsets = consumer.endOffsets(topicPartitions); + + return topicPartitions.stream() + .map(tp -> { + var topicOffset = Optional.ofNullable(topicOffsetMap.get(tp)) + .map(OffsetAndMetadata::offset) + .orElse(0L); + return new KafkaConsumerGroupMemberPartitionAssignment(tp.partition(), tp.topic(), + getLag(topicOffset, endOffsets.get(tp))); + }) + .collect(Collectors.toSet()); + } + } + + private long getLag(long topicOffset, long endOffset) { + return endOffset - topicOffset; + } + + public Map getOffset(KafkaOffsetRequest request) throws ExecutionException, InterruptedException { + return kafkaTopicClient.getPagePartitionOffset(request.getTopicName(), request.getRequestedPartitions(), + request.getOrder()); + } + public KafkaAclInfo getAclInfo() throws InterruptedException, ExecutionException { var clusterInfo = clusterInfo(kafkaAdminClient.getCluster()); var entries = new ArrayList(); diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/Order.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/Order.java new file mode 100644 index 0000000000000..a94a5565c4a0f --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/Order.java @@ -0,0 +1,6 @@ +package io.quarkus.kafka.client.runtime.ui.model; + +public enum Order { + OLD_FIRST, + NEW_FIRST +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/converter/KafkaModelConverter.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/converter/KafkaModelConverter.java new file mode 100644 index 0000000000000..5eaad0173d129 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/converter/KafkaModelConverter.java @@ -0,0 +1,20 @@ +package io.quarkus.kafka.client.runtime.ui.model.converter; + +import java.util.Optional; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.utils.Bytes; + +import io.quarkus.kafka.client.runtime.ui.model.response.KafkaMessage; + +public class KafkaModelConverter { + public KafkaMessage convert(ConsumerRecord message) { + return new KafkaMessage( + message.topic(), + message.partition(), + message.offset(), + message.timestamp(), + Optional.ofNullable(message.key()).map(Bytes::toString).orElse(null), + Optional.ofNullable(message.value()).map(Bytes::toString).orElse(null)); + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessageCreateRequest.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessageCreateRequest.java new file mode 100644 index 0000000000000..5dcbebb32fdaa --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessageCreateRequest.java @@ -0,0 +1,39 @@ +package io.quarkus.kafka.client.runtime.ui.model.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties("action") +public class KafkaMessageCreateRequest { + + //TODO: add headers + private String topic; + private Integer partition; + private String value; + private String key; + + public KafkaMessageCreateRequest() { + } + + public KafkaMessageCreateRequest(String topic, Integer partition, String value, String key) { + this.topic = topic; + this.partition = partition; + this.value = value; + this.key = key; + } + + public String getTopic() { + return topic; + } + + public Integer getPartition() { + return partition; + } + + public String getValue() { + return value; + } + + public String getKey() { + return key; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessagesRequest.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessagesRequest.java new file mode 100644 index 0000000000000..71fda0e79d8a6 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaMessagesRequest.java @@ -0,0 +1,51 @@ +package io.quarkus.kafka.client.runtime.ui.model.request; + +import java.util.Map; + +import io.quarkus.kafka.client.runtime.ui.model.Order; + +public class KafkaMessagesRequest { + private String topicName; + private Order order; + private int pageSize; + private Integer pageNumber; + + private Map partitionOffset; + + public KafkaMessagesRequest() { + } + + public KafkaMessagesRequest(String topicName, Order order, int pageSize, int pageNumber) { + this.topicName = topicName; + this.order = order; + this.pageSize = pageSize; + this.pageNumber = pageNumber; + } + + public KafkaMessagesRequest(String topicName, Order order, int pageSize, Map partitionOffset) { + this.topicName = topicName; + this.order = order; + this.pageSize = pageSize; + this.partitionOffset = partitionOffset; + } + + public String getTopicName() { + return topicName; + } + + public Order getOrder() { + return order; + } + + public int getPageSize() { + return pageSize; + } + + public int getPageNumber() { + return pageNumber; + } + + public Map getPartitionOffset() { + return partitionOffset; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaOffsetRequest.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaOffsetRequest.java new file mode 100644 index 0000000000000..f9fa52cdb7369 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/request/KafkaOffsetRequest.java @@ -0,0 +1,32 @@ +package io.quarkus.kafka.client.runtime.ui.model.request; + +import java.util.List; + +import io.quarkus.kafka.client.runtime.ui.model.Order; + +public class KafkaOffsetRequest { + private String topicName; + private List requestedPartitions; + private Order order; + + public KafkaOffsetRequest() { + } + + public KafkaOffsetRequest(String topicName, List requestedPartitions, Order order) { + this.topicName = topicName; + this.requestedPartitions = requestedPartitions; + this.order = order; + } + + public String getTopicName() { + return topicName; + } + + public List getRequestedPartitions() { + return requestedPartitions; + } + + public Order getOrder() { + return order; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroup.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroup.java new file mode 100644 index 0000000000000..e6506837534eb --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroup.java @@ -0,0 +1,56 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +import java.util.Collection; + +public class KafkaConsumerGroup { + private String name; + private String state; + private String coordinatorHost; + private int coordinatorId; + // The assignment strategy + private String protocol; + private long lag; + private Collection members; + + public KafkaConsumerGroup() { + } + + public KafkaConsumerGroup(String name, String state, String coordinatorHost, int coordinatorId, String protocol, long lag, + Collection members) { + this.name = name; + this.state = state; + this.coordinatorHost = coordinatorHost; + this.coordinatorId = coordinatorId; + this.protocol = protocol; + this.lag = lag; + this.members = members; + } + + public String getName() { + return name; + } + + public String getState() { + return state; + } + + public String getCoordinatorHost() { + return coordinatorHost; + } + + public int getCoordinatorId() { + return coordinatorId; + } + + public String getProtocol() { + return protocol; + } + + public long getLag() { + return lag; + } + + public Collection getMembers() { + return members; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMember.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMember.java new file mode 100644 index 0000000000000..338890414b702 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMember.java @@ -0,0 +1,38 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +import java.util.Collection; + +public class KafkaConsumerGroupMember { + private String memberId; + private String clientId; + private String host; + + private Collection partitions; + + public KafkaConsumerGroupMember() { + } + + public KafkaConsumerGroupMember(String memberId, String clientId, String host, + Collection partitions) { + this.memberId = memberId; + this.clientId = clientId; + this.host = host; + this.partitions = partitions; + } + + public String getMemberId() { + return memberId; + } + + public String getClientId() { + return clientId; + } + + public String getHost() { + return host; + } + + public Collection getPartitions() { + return partitions; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMemberPartitionAssignment.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMemberPartitionAssignment.java new file mode 100644 index 0000000000000..4a722e76d6385 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaConsumerGroupMemberPartitionAssignment.java @@ -0,0 +1,29 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +public class KafkaConsumerGroupMemberPartitionAssignment { + + private int partition; + private String topic; + private long lag; + + public KafkaConsumerGroupMemberPartitionAssignment() { + } + + public KafkaConsumerGroupMemberPartitionAssignment(int partition, String topic, long lag) { + this.partition = partition; + this.topic = topic; + this.lag = lag; + } + + public int getPartition() { + return partition; + } + + public String getTopic() { + return topic; + } + + public long getLag() { + return lag; + } +} 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 index d095170b8bdf8..f8a63d09638f5 100644 --- 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 @@ -6,14 +6,17 @@ public class KafkaInfo { private String broker; private KafkaClusterInfo clusterInfo; private List topics; + private List consumerGroups; public KafkaInfo() { } - public KafkaInfo(String broker, KafkaClusterInfo clusterInfo, List topics) { + public KafkaInfo(String broker, KafkaClusterInfo clusterInfo, List topics, + List consumerGroups) { this.broker = broker; this.clusterInfo = clusterInfo; this.topics = topics; + this.consumerGroups = consumerGroups; } public String getBroker() { @@ -28,4 +31,7 @@ public KafkaClusterInfo getClusterInfo() { return clusterInfo; } + public List getConsumerGroups() { + return consumerGroups; + } } diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessage.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessage.java new file mode 100644 index 0000000000000..4b4e246994a94 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessage.java @@ -0,0 +1,43 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +public class KafkaMessage { + private final String topic; + private final int partition; + private final long offset; + private final long timestamp; + private final String key; + private final String value; + + public KafkaMessage(String topic, int partition, long offset, long timestamp, String key, String value) { + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.timestamp = timestamp; + this.key = key; + this.value = value; + } + + public String getTopic() { + return topic; + } + + public int getPartition() { + return partition; + } + + public long getOffset() { + return offset; + } + + public long getTimestamp() { + return timestamp; + } + + public String getKey() { + return key; + } + + public String getValue() { + return value; + } +} diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessagePage.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessagePage.java new file mode 100644 index 0000000000000..c57aaa6ce5178 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/model/response/KafkaMessagePage.java @@ -0,0 +1,22 @@ +package io.quarkus.kafka.client.runtime.ui.model.response; + +import java.util.Collection; +import java.util.Map; + +public class KafkaMessagePage { + private final Map nextOffsets; + private final Collection messages; + + public KafkaMessagePage(Map nextOffsets, Collection messages) { + this.nextOffsets = nextOffsets; + this.messages = messages; + } + + public Map getNextOffsets() { + return nextOffsets; + } + + public Collection getMessages() { + return messages; + } +} 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 index b678b50afc344..ab5595d7a8488 100644 --- 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 @@ -5,15 +5,17 @@ public class KafkaTopic { private String topicId; private int partitionsCount; private boolean internal; + private long nmsg = 0; public KafkaTopic() { } - public KafkaTopic(String name, String topicId, int partitionsCount, boolean internal) { + public KafkaTopic(String name, String topicId, int partitionsCount, boolean internal, long nmsg) { this.name = name; this.topicId = topicId; this.partitionsCount = partitionsCount; this.internal = internal; + this.nmsg = nmsg; } public String getName() { @@ -32,6 +34,10 @@ public boolean isInternal() { return internal; } + public long getNmsg() { + return nmsg; + } + public String toString() { StringBuilder sb = new StringBuilder(name); sb.append(" : ").append(topicId); diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/util/ConsumerFactory.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/util/ConsumerFactory.java new file mode 100644 index 0000000000000..be2c140530860 --- /dev/null +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/ui/util/ConsumerFactory.java @@ -0,0 +1,37 @@ +package io.quarkus.kafka.client.runtime.ui.util; + +import java.util.*; + +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.serialization.BytesDeserializer; +import org.apache.kafka.common.utils.Bytes; + +public class ConsumerFactory { + + public static Consumer createConsumer(String topicName, Integer requestedPartition, + Map commonConfig) { + return createConsumer(List.of(new TopicPartition(topicName, requestedPartition)), commonConfig); + } + + // We must create a new instance per request, as we might have multiple windows open, each with different pagination, filter and thus different cursor. + public static Consumer createConsumer(Collection requestedPartitions, + Map commonConfig) { + Map config = new HashMap<>(commonConfig); + //TODO: make generic? + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, BytesDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, BytesDeserializer.class); + + config.put(ConsumerConfig.CLIENT_ID_CONFIG, "kafka-ui-" + UUID.randomUUID()); + + // For pagination, we require manual management of offset pointer. + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); + + var consumer = new KafkaConsumer(config); + consumer.assign(requestedPartitions); + return consumer; + } + +} diff --git a/extensions/kafka-client/ui/src/main/webapp/config.js b/extensions/kafka-client/ui/src/main/webapp/config.js new file mode 100644 index 0000000000000..1c141c4c1e97c --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/config.js @@ -0,0 +1,4 @@ +export const api = '/kafka-admin'; +export const logo = 'quarkus_icon_rgb_reverse.svg'; +export const faviconLogo = 'favicon.ico'; +export const ui = ''; \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/index.html b/extensions/kafka-client/ui/src/main/webapp/index.html index 975d965858710..181056ca3ead0 100644 --- a/extensions/kafka-client/ui/src/main/webapp/index.html +++ b/extensions/kafka-client/ui/src/main/webapp/index.html @@ -35,6 +35,86 @@ min-height: 90vh; } + .link { + background: none; + border: none; + } + + .top-margin { + margin-top: 1em; + } + + .left-margin { + margin-left: 1em; + } + + .left-padding { + padding-left: 1em; + } + + .shown { + display: flex; + height: auto; + min-width: 100%; + } + + .text-shown { + display: inline; + } + + .hidden { + display: none + } + + .nav-item:hover > .nav-row > a { + background-color: #005fff; + color: #e9ecef; + } + + .nav-item:hover > .nav-row > i { + background-color: #005fff; + color: #e9ecef; + } + + #navbar-list > .nav-item:hover { + background-color: #005fff; + color: #e9ecef; + } + + .table-hover:hover { + cursor: pointer; + } + + .multiselect-container > li > a > label { + padding-left: 15px !important; + } + + .page { + min-height: calc(100vh - 135px); + } + + .table-hover:hover { + cursor: pointer; + } + + .pointer { + cursor: pointer; + } + + .no-hover { + background-color: white; + cursor: default; + } + + .no-hover:hover { + background-color: white !important; + cursor: default; + } + + .icon-rotated { + transform: rotate(90deg); + } + .navbar-brand img { border-right: 1px solid darkgrey; padding-right: 10px; @@ -50,6 +130,57 @@ padding: 0px; } + .float-plus-btn { + position: fixed; + bottom: 60px; + right: 60px; + border-radius: 100%; + height: 50px; + width: 50px; + } + + .breadcrumb-item::before { + float: left; + padding-right: 0.5rem; + color: #007bff; + content: "〉"; + } + + .breadcrumb-item + .breadcrumb-item::before { + float: left; + padding-right: 0.5rem; + color: #007bff; + content: "〉"; + } + + .breadcrumb { + background-color: #343a40; + margin-bottom: 0; + padding: 0 0 0 5px; + } + + .bi-trash-fill:hover { + color: #007bff; + } + + .collapse-content { + max-width: 1200px; + } + + .thead-multiselect { + background-color: #343a40; + color: white; + border: 0px; + font-weight: bold; + } + + .thead-text { + color: white; + } + + #msg-table-holder { + min-width: 100%; + } @@ -64,6 +195,194 @@
+ + + + -
- Schema registry is not implemeted yet.
-
@@ -173,30 +489,6 @@
-
-
-
- Kafka cluster id: 
- Controller node (broker): 
- ACL operations: 
-
-
-

Access Control Lists

-
- - - - - - - - - - - -
OperationPrinicipalPermissionsPattern
-
-
@@ -220,11 +512,19 @@

Cluster nodes

+
+ + + diff --git a/extensions/kafka-client/ui/src/main/webapp/kafka_ui.js b/extensions/kafka-client/ui/src/main/webapp/kafka_ui.js new file mode 100644 index 0000000000000..c07fdfd378217 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/kafka_ui.js @@ -0,0 +1,11 @@ +import Navigator from './pages/navigator.js' +import {setLogo} from "./util/logo.js"; + +const navigator = new Navigator(); +$(document).ready( + () => { + setLogo(); + navigator.navigateToDefaultPage(); + } +); + diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js new file mode 100644 index 0000000000000..cfd39d4d8be2d --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js @@ -0,0 +1,86 @@ +import {CollapseRow, createTableHead, createTableItem, createTableItemHtml} from "../util/contentManagement.js"; + +export default class ConsumerGroupDetailsPage { + constructor(containerId) { + this.containerId = containerId; + Object.getOwnPropertyNames(ConsumerGroupDetailsPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + open(params) { + const membersData = params[1]; + let consumerGroupsTable = $('#consumer-group-details-table tbody'); + consumerGroupsTable.empty(); + for (let i = 0; i < membersData.length; i++) { + const d = membersData[i]; + const groupId = "group-" + window.crypto.randomUUID(); + + let tableRow = $(""); + let collapseRow; + if (d.partitions.length > 0) { + collapseRow = new CollapseRow(groupId); + tableRow.append(createTableItemHtml(collapseRow.arrow)); + } else { + tableRow.append(createTableItem("")); + } + + const memberId = $("") + .text(d.clientId); + const id = d.memberId.substring(d.clientId.length); + const text = $("

") + .append(memberId) + .append(id); + tableRow.append(createTableItemHtml(text)); + tableRow.append(createTableItem(d.host)); + tableRow.append(createTableItem("" + new Set(d.partitions.map(x => x.partition)).size)); + tableRow.append(createTableItem("" + d.partitions.map(x => x.lag).reduce((l, r) => l + r, 0))); + consumerGroupsTable.append(tableRow); + + if (d.partitions.length > 0) { + const content = this.createConsumerGroupCollapseInfo(d); + tableRow + .addClass("pointer") + .click(collapseRow.collapse); + consumerGroupsTable.append( + collapseRow + .getCollapseContent(tableRow.children().length, content) + .addClass("no-hover") + ); + } + } + } + + createConsumerGroupCollapseInfo(dataItem) { + const collapseContent = $("") + .addClass("table") + .addClass("table-sm") + .addClass("no-hover"); + + const headers = $("") + .addClass("no-hover") + .append(createTableHead("Topic")) + .append(createTableHead("Partition")) + .append(createTableHead("Lag")); + const head = $("") + .append(headers); + + const body = $(""); + for (let partition of dataItem.partitions) { + const row = $("") + .addClass("no-hover"); + row.append(createTableItemHtml(partition.topic)) + row.append(createTableItemHtml(partition.partition)) + row.append(createTableItemHtml(partition.lag)) + body.append(row); + } + + collapseContent.append(head); + collapseContent.append(body); + + return collapseContent; + } + +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js new file mode 100644 index 0000000000000..4a8500fab0c0e --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js @@ -0,0 +1,48 @@ +import {createTableItem} from "../util/contentManagement.js"; +import {doPost, errorPopUp} from "../web/web.js"; +import {pages} from "./navigator.js"; +import {toggleSpinner} from "../util/spinner.js"; + +export default class ConsumerGroupPage { + constructor(navigator, containerId) { + this.containerId = containerId; + this.navigator = navigator; + Object.getOwnPropertyNames(ConsumerGroupPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + open() { + toggleSpinner(this.containerId); + const req = { + action: "getInfo", key: "0", value: "0" + }; + doPost(req, (data) => { + this.updateConsumerGroups(data.consumerGroups); + toggleSpinner(this.containerId); + }, data => { + errorPopUp("Error getting Kafka info: ", data); + toggleSpinner(this.containerId); + }); + } + + updateConsumerGroups(data) { + let consumerGroupsTable = $('#consumer-groups-table tbody'); + consumerGroupsTable.empty(); + for (let i = 0; i < data.length; i++) { + const d = data[i]; + let tableRow = $(""); + tableRow.append(createTableItem(d.state)); + tableRow.append(createTableItem(d.name)); + tableRow.append(createTableItem(d.coordinatorId)); + tableRow.append(createTableItem(d.protocol)); + tableRow.append(createTableItem(d.members.length)); + tableRow.append(createTableItem(d.lag)); + const self = this; + tableRow.click(() => self.navigator.navigateTo(pages.CONSUMER_GROUPS_DETAILS, [d.name, d.members])); + consumerGroupsTable.append(tableRow); + } + } +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/messagesPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/messagesPage.js new file mode 100644 index 0000000000000..f009847be3826 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/messagesPage.js @@ -0,0 +1,449 @@ +import {doPost, errorPopUp} from "../web/web.js"; +import timestampToFormattedString from "../util/datetimeUtil.js"; +import {CollapseRow, createTableItem, createTableItemHtml} from "../util/contentManagement.js"; +import {toggleSpinner} from "../util/spinner.js"; + +const MODAL_KEY_TAB = "header-key-tab-pane"; +const PAGE_SIZE = 20; +const NEW_FIRST = "NEW_FIRST"; +const OLD_FIRST = "OLD_FIRST"; +const MESSAGES_SPINNER = "message-load-spinner"; +const MESSAGES_TABLE_BODY = "msg-table-body"; +const MESSAGES_TABLE_HOLDER = "msg-table-holder"; + +export default class MessagesPage { + constructor(containerId) { + this.containerId = containerId; + this.registerButtonHandlers(); + Object.getOwnPropertyNames(MessagesPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + registerButtonHandlers() { + $("#open-create-msg-modal-btn").click(() => { + $('#create-msg-modal').modal('show'); + this.setActiveTab(MODAL_KEY_TAB); + }); + + $('#send-msg-btn').click(this.createMessage.bind(this)); + + $('.close-modal-btn').click(() => { + $('.modal').modal('hide'); + this.setActiveTab(MODAL_KEY_TAB); + }); + + $('#msg-page-partition-select').multiselect({ + buttonClass: 'thead-multiselect', + includeSelectAllOption: true, + filterPlaceholder: 'Partitions', + selectAllText: 'Select All', + nonSelectedText: 'Partitions', + buttonText: function () { + return 'Partitions'; + } + }); + + $("#timestamp-sort-header").click(() => { + this.toggleSorting(); + window.currentContext.currentPage = 1; + this.loadMessages(); + }); + + $("#msg-page-partition-select").change(() => { + window.currentContext.currentPage = 1; + this.loadMessages(); + }); + + $(".previous").click(() => { + if (window.currentContext.currentPage === 1) return; + window.currentContext.currentPage = window.currentContext.currentPage - 1; + this.loadMessages(); + }) + + $(".next").click(() => { + if (window.currentContext.currentPage === this.getMaxPageNumber()) return; + window.currentContext.currentPage = window.currentContext.currentPage + 1; + this.loadMessages(); + }) + + $("#reload-msg-btn").click(() => { + currentContext.pagesCache = new Map(); + this.loadMessages(); + }); + } + + toggleSorting() { + if (currentContext.currentSorting === NEW_FIRST) { + currentContext.currentSorting = OLD_FIRST; + $("#timestamp-sort-icon") + .removeClass("bi-chevron-double-down") + .addClass("bi-chevron-double-up"); + } else { + currentContext.currentSorting = NEW_FIRST; + $("#timestamp-sort-icon") + .addClass("bi-chevron-double-down") + .removeClass("bi-chevron-double-up"); + } + } + + loadMessages() { + toggleSpinner(MESSAGES_TABLE_HOLDER, MESSAGES_SPINNER); + this.getPage(currentContext.currentPage, this.onMessagesLoaded, this.onMessagesFailed); + this.redrawPageNav(); + } + + open(params) { + toggleSpinner(MESSAGES_TABLE_HOLDER, MESSAGES_SPINNER); + const topicName = params[0]; + window.currentContext = { + topicName: topicName, + currentPage: 1, //always start with first page + pagesCache: new Map(), + currentSorting: NEW_FIRST + }; + + this.clearMessageTable(); + + new Promise((resolve, reject) => { + this.requestPartitions(topicName, resolve, reject); + }).then((data) => { + this.onPartitionsLoaded(data); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }).then(() => { + this.loadMaxPageNumber(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }).then(() => { + this.getPage(currentContext.currentPage, this.onMessagesLoaded, this.onMessagesFailed); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .catch(() => errorPopUp("Failed loading page.")); + } + + // Key format: ORDER-partition1-partition2-...-partitionN-pageNumber. Like: NEW_FIRST-0-1-17 + generateCacheKey(pageNumber) { + const order = this.getOrder(); + const partitions = this.getPartitions(); + const partitionsKeyPart = partitions.reduce((partialKey, str) => partialKey + "-" + str, 0); + + return order + partitionsKeyPart + "-" + pageNumber; + } + + requestPartitions(topicName, onPartitionsLoaded, onPartitionsFailed) { + const rq = { + action: "getPartitions", topicName: topicName + } + + doPost(rq, onPartitionsLoaded, onPartitionsFailed); + } + + onPartitionsLoaded(data) { + let msgModalPartitionSelect = $('#msg-modal-partition-select'); + let msgPagePartitionSelect = $('#msg-page-partition-select'); + msgModalPartitionSelect.empty(); + msgPagePartitionSelect.empty(); + + msgModalPartitionSelect.append($(""); + const groupId = "group-" + window.crypto.randomUUID(); + const collapseRow = new CollapseRow(groupId); + tableRow.append(createTableItemHtml(collapseRow.arrow)); + + tableRow.append(createTableItem(messages[i].offset)); + tableRow.append(createTableItem(messages[i].partition)); + tableRow.append(createTableItem(timestampToFormattedString(messages[i].timestamp))); + tableRow.append(createTableItem(messages[i].key)); + + const value = messages[i].value; + const maxMsgLength = 75; + if (value.length < maxMsgLength) { + tableRow.append(createTableItem(value)); + } else { + tableRow.append(createTableItem(value.slice(0, maxMsgLength) + "...")); + } + tableRow.append(createTableItem()); + tableRow + .addClass("pointer") + .click(collapseRow.collapse); + msgTableBody.append(tableRow); + msgTableBody.append(collapseRow.getCollapseContent(tableRow.children().length, this.createMessageCollapseItem(value))); + } + + currentContext.lastOffset = data.partitionOffset; + toggleSpinner(MESSAGES_TABLE_HOLDER, MESSAGES_SPINNER); + } + + createMessageCollapseItem(fullMessage) { + return $("
") + .text(fullMessage); + } + + toggleContent() { + return (event) => { + const textBlock = $(event.target); + const dots = textBlock.find(".dots"); + const hiddenText = textBlock.find(".hidden-text"); + + if (dots.hasClass("hidden")) { + dots.removeClass("hidden"); + dots.addClass("text-shown"); + hiddenText.removeClass("text-shown"); + hiddenText.addClass("hidden"); + } else { + dots.removeClass("text-shown"); + dots.addClass("hidden"); + hiddenText.removeClass("hidden"); + hiddenText.addClass("text-shown"); + } + }; + } + + onMessagesFailed(data, errorType, error) { + console.error("Error getting topic messages"); + } + + requestCreateMessage() { + const topicName = currentContext.topicName; + let partition = $('#msg-modal-partition-select option:selected').val(); + if (partition === 'any') partition = null; + + let valueTextarea = $('#msg-value-textarea'); + let keyTextarea = $('#msg-key-textarea'); + const rq = { + action: "createMessage", + topic: topicName, + partition: partition, + value: valueTextarea.val(), + key: keyTextarea.val() + }; + + // TODO: print out partitions count on topics page + doPost(rq, data => { + currentContext.pagesCache = new Map(); + new Promise(this.loadMaxPageNumber) + .then(this.loadMessages) + .catch(() => errorPopUp("Failed")); + }, (data, errorType, error) => { + errorPopUp("Failed to reload messages."); + }); + } + + setActiveTab(tab) { + $('.nav-tabs button[href="#' + tab + '"]').click(); + }; + + createMessage() { + this.requestCreateMessage(); + + // Clean inputs for future reuse of modal. + $('#create-msg-modal').modal('hide'); + $('#msg-value-textarea').val(""); + $('#msg-key-textarea').val(""); + $('#msg-modal-partition-select').val("any"); + $('#msg-modal-type-select').val("text"); + + $('body').removeClass('modal-open'); + $('.modal-backdrop').remove(); + + this.setActiveTab(MODAL_KEY_TAB); + } + + clearMessageTable() { + $('#msg-table-body').empty(); + } + + redrawPageNav() { + //TODO: add GOTO page input + const previous = $(".previous"); + const next = $(".next"); + + previous.removeClass("disabled"); + next.removeClass("disabled"); + + const maxPageNumber = this.getMaxPageNumber(); + const currentPage = currentContext.currentPage; + let pages = [currentPage]; + + if (currentPage > 1) { + pages.unshift(currentPage - 1); + } + if (currentPage < maxPageNumber) { + pages.push(currentPage + 1); + } + + if (currentPage === 1) { + previous.addClass("disabled"); + if (maxPageNumber > 2) { + pages.push(currentPage + 2); + } + } + if (currentPage === maxPageNumber) { + next.addClass("disabled"); + if (maxPageNumber > 2) { + pages.unshift(currentPage - 2); + } + } + + const pagination = $("#msg-pagination"); + + // Remove all page children numbers. + while (pagination.children().length !== 2) { + pagination.children()[1].remove(); + } + + for (const p of pages) { + let a = $("") + .text("" + p) + .addClass("page-link"); + let li = $("
  • ") + .addClass("page-item") + .click(() => { + toggleSpinner(MESSAGES_TABLE_HOLDER, MESSAGES_SPINNER); + currentContext.currentPage = p; + this.getPage(p, this.onMessagesLoaded, this.onMessagesFailed); + this.redrawPageNav(); + }); + + if (p === currentPage) { + li.addClass("active"); + } + li.append(a); + + const lastPosition = pagination.children().length - 1; + li.insertBefore(".next"); + } + } + + requestOffset(topicName, order, onOffsetLoaded, onOffsetFailed, partitions) { + const req = { + action: "getOffset", + topicName: topicName, + order: order, + requestedPartitions: partitions === undefined ? this.getPartitions() : partitions + }; + doPost(req, onOffsetLoaded, onOffsetFailed); + } + + // TODO: add possibility to hide panel on the left + loadMaxPageNumber() { + const partitions = this.getPartitions(); + this.requestOffset( + currentContext.topicName, + NEW_FIRST, + (data) => { + currentContext.partitionOffset = new Map( + Object.entries(data).map(x => [parseInt(x[0]), x[1]]) + ); + this.redrawPageNav(); + }, + (data, errorType, error) => { + console.error("Error getting max page number."); + }, + partitions + ); + } + + getMaxPageNumber() { + const partitions = this.getPartitions(); + const totalElements = partitions.map(x => { + const a = currentContext.partitionOffset.get(x) + return a; + }) + .reduce((partialSum, a) => partialSum + a, 0); + return Math.max(Math.ceil(totalElements / PAGE_SIZE), 1); + } + + getOrder() { + return currentContext.currentSorting; + } + +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/navigator.js b/extensions/kafka-client/ui/src/main/webapp/pages/navigator.js new file mode 100644 index 0000000000000..cd5e66615a4aa --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/navigator.js @@ -0,0 +1,175 @@ +import MessagesPage from "./messagesPage.js"; +import TopicsPage from "./topicsPage.js"; +import ConsumerGroupPage from "./consumerGroupPage.js"; +import ConsumerGroupDetailsPage from "./consumerGroupDetailsPage.js"; +import AccessControlListPage from "./accessControlListPage.js"; +import NodesPage from "./nodesPage.js"; +import {createIcon} from "../util/contentManagement.js"; + +export const pages = { + TOPICS: "topics-page", + SCHEMA: "schema-page", + CONSUMER_GROUPS: "consumer-groups-page", + CONSUMER_GROUPS_DETAILS: "consumer-groups-details-page", + ACCESS_CONTROL_LIST: "access-control-list-page", + NODES: "nodes-page", + TOPIC_MESSAGES: "topic-messages-page", + DEFAULT: "topics-page" +} + +export default class Navigator { + constructor() { + this.registerNavbar(); + } + + allPages = { + [pages.TOPICS]: { + header: "Topics", + showInNavbar: true, + instance: new TopicsPage(this, pages.TOPICS), + icon: "bi-collection" + }, + [pages.SCHEMA]: { + header: "Schema registry", + showInNavbar: true, + icon: "bi-file-code" + }, + [pages.CONSUMER_GROUPS]: { + header: "Consumer groups", + showInNavbar: true, + instance: new ConsumerGroupPage(this, pages.CONSUMER_GROUPS), + icon: "bi-inboxes" + }, + [pages.ACCESS_CONTROL_LIST]: { + header: "Access control list", + showInNavbar: true, + instance: new AccessControlListPage(pages.ACCESS_CONTROL_LIST), + icon: "bi-shield-lock" + }, + [pages.NODES]: { + header: "Nodes", + showInNavbar: true, + instance: new NodesPage(pages.NODES), + icon: "bi-diagram-3" + }, + [pages.TOPIC_MESSAGES]: { + header: "Messages", + showInNavbar: false, + instance: new MessagesPage(pages.TOPIC_MESSAGES), + parent: pages.TOPICS + }, + [pages.CONSUMER_GROUPS_DETAILS]: { + header: "Consumer group details", + showInNavbar: false, + instance: new ConsumerGroupDetailsPage(pages.CONSUMER_GROUPS_DETAILS), + parent: pages.CONSUMER_GROUPS + } + }; + + registerNavbar() { + const keys = Object.keys(this.allPages); + const navbar = $("#navbar-list"); + navbar.empty(); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = this.allPages[key]; + if (!value.showInNavbar) continue; + const navItem = $("
  • ") + .addClass("nav-item") + .addClass("left-padding") + .addClass("pointer"); + + const navHolder = $("
    ") + .addClass("d-flex") + .addClass("left-margin") + .addClass("nav-row") + .click(() => this.navigateTo(key)); + + const icon = createIcon(value.icon) + .addClass("align-self-center"); + const navLink = $("", { + text: value.header, + href: "#" + }) + .addClass("nav-link") + .addClass("active") + .addClass("link"); + navHolder.append(icon); + navHolder.append(navLink); + navItem.append(navHolder); + navbar.append(navItem); + } + } + + navigateTo(requestedPage, params) { + const keys = Object.keys(this.allPages); + for (let i = 0; i < keys.length; i++) { + const elementName = keys[i]; + const d = $("#" + elementName); + if (d !== null) { + if (elementName !== requestedPage) { + d.removeClass("shown") + .addClass("hidden"); + } else { + d.removeClass("hidden") + .addClass("shown"); + this.open(requestedPage, params); + } + } else { + console.error("Can not find page div: ", keys[i]); + } + } + + this.navigateBreadcrumb(requestedPage, params); + } + + navigateToDefaultPage() { + this.navigateTo(pages.DEFAULT); + } + + open(pageId, params) { + const value = this.allPages[pageId]; + value.instance.open(params); + } + + navigateBreadcrumb(page, params) { + const breadcrumb = $("#nav-breadcrumb"); + breadcrumb.empty(); + + let nextPage = this.allPages[page]; + let pageId = page; + + let i = 0; + while (nextPage !== undefined) { + let li; + // We only need to append possible params to the very first element. + if (i === 0) { + li = this.createBreadcrumbItem(nextPage.header, pageId, true, params); + } else { + li = this.createBreadcrumbItem(nextPage.header, pageId, false); + } + breadcrumb.prepend(li); + pageId = nextPage.parent; + nextPage = this.allPages[pageId]; + i++; + } + } + + createBreadcrumbItem(text, pageId, isActive, params) { + let breadcrumbText = text; + if (params !== undefined && params.length > 0 && (params[0] !== null && params[0] !== undefined)) { + breadcrumbText = text + " (" + params[0] + ")"; + } + const a = $("", {href: "#", text: breadcrumbText}) + .click(() => this.navigateTo(pageId, params)); + if (isActive) { + a.addClass("active"); + } + + const li = $("
  • ") + .addClass("breadcrumb-item"); + li.append(a); + return li; + } +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/topicsPage.js b/extensions/kafka-client/ui/src/main/webapp/pages/topicsPage.js new file mode 100644 index 0000000000000..28156717578c8 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/pages/topicsPage.js @@ -0,0 +1,188 @@ +import {doPost, errorPopUp} from "../web/web.js"; +import {createIcon, createTableItem, createTableItemHtml, hideItem, showItem} from "../util/contentManagement.js"; +import {pages} from "./navigator.js"; + +export default class TopicsPage { + constructor(navigator, containerId) { + this.navigator = navigator; + this.containerId = containerId; + this.registerButtonHandlers(); + + // TODO: move to common function with comment + Object.getOwnPropertyNames(TopicsPage.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + open() { + window.currentContext = {}; + this.requestTopics(this.onTopicsLoaded, this.onTopicsFailed); + } + + registerButtonHandlers() { + + const topicNameInput = $("#topic-name-modal-input"); + $("#create-topic-btn").click(() => { + if (!this.validateTopicName(topicNameInput.val())) { + this.showErrorIfInvalid(topicNameInput.val(), this.validateTopicName, topicNameValidationErrorBox); + return; + } + + this.createTopic(this.onTopicsLoaded, this.onTopicsFailed); + $('#create-topic-modal').modal('hide'); + $('#topic-name-modal-input').val(""); + $('#partitions-modal-input').val(""); + $('#replications-modal-input').val(""); + }) + + $("#open-create-topic-modal-btn").click(() => { + this.loadNodesCount(); + $('#create-topic-modal').modal('show'); + }); + + $('.close-modal-btn').click(() => { + hideItem($(".modal")); + hideItem($("#topic-creation-validation-msg-box")); + hideItem($("#topic-name-validation-msg")); + hideItem($("#replication-validation-msg")); + }); + + $("#delete-topic-btn").click(() => { + const currentTopic = window.currentContext.topicName; + this.deleteTopic(currentTopic, this.deleteTopicRow, this.onTopicsFailed) + $("#delete-topic-modal").modal("hide"); + }); + + const topicNameValidationErrorBox = $("#topic-name-validation-msg"); + topicNameInput.keyup(() => this.showErrorIfInvalid(topicNameInput.val(), this.validateTopicName, topicNameValidationErrorBox)); + topicNameInput.change(() => this.showErrorIfInvalid(topicNameInput.val(), this.validateTopicName, topicNameValidationErrorBox)); + + const replicationInput = $("#replications-modal-input"); + replicationInput.keyup(() => { + const value = replicationInput.val(); + this.showErrorIfInvalid(value, this.validateReplicationFactor, $("#replication-validation-msg")); + }); + } + + loadNodesCount() { + const req = { + action: "getInfo" + }; + doPost(req, (data) => { + window.currentContext.nodesCount = data.clusterInfo.nodes.length; + }, data => { + errorPopUp("Could not obtain nodes count."); + }); + } + + showErrorIfInvalid(value, validationFunction, errBoxSelector) { + const valid = validationFunction(value); + if (!valid) { + showItem($("#topic-creation-validation-msg-box")); + showItem(errBoxSelector); + $("#create-topic-btn") + .addClass("disabled") + .attr("disabled", true); + } else { + hideItem(errBoxSelector); + const topicMsgValidationBoxChildren = $("#topic-creation-validation-msg-box span"); + const allChildrenHidden = topicMsgValidationBoxChildren + .filter((x) => !$(x).hasClass("hidden")) + .length > 0; + if (allChildrenHidden) { + hideItem($("#topic-creation-validation-msg-box")); + $("#create-topic-btn") + .removeClass("disabled") + .attr("disabled", false); + } + } + } + + validateTopicName(name) { + const legalChars = /^[a-zA-Z\d\.\_]+$/; + const maxNameLength = 255; + return legalChars.test(name) && name.length < maxNameLength; + } + + validateReplicationFactor(replicationFactor) { + return currentContext.nodesCount >= replicationFactor; + } + + requestTopics(onTopicsLoaded, onTopicsFailed) { + const req = { + action: "getTopics" + }; + doPost(req, onTopicsLoaded, onTopicsFailed); + } + + onTopicsLoaded(data) { + let tableBody = $('#topics-table tbody'); + tableBody.empty(); + + for (let i = 0; i < data.length; i++) { + let tableRow = $("
  • "); + let d = data[i]; + tableRow.append(createTableItem(d.name)); + tableRow.append(createTableItem(d.topicId)); + tableRow.append(createTableItem(d.partitionsCount)); + tableRow.append(createTableItem(("" + d.nmsg))); + + const deleteIcon = createIcon("bi-trash-fill"); + const deleteBtn = $("") + .addClass("btn") + .click((event) => { + window.currentContext.topicName = d.name; + $("#delete-topic-modal").modal("show"); + $("#delete-topic-name-span").text(d.name); + event.stopPropagation(); + }) + .append(deleteIcon); + + + tableRow.click(() => { + self.navigator.navigateTo(pages.TOPIC_MESSAGES, [d.name]); + }); + const controlHolder = $("
    ") + .append(deleteBtn); + tableRow.append(createTableItemHtml(controlHolder)); + + const self = this; + + tableBody.append(tableRow); + } + } + + onTopicsFailed(data) { + errorPopUp("Error getting topics: ", data); + } + + createTopic(onTopicsLoaded, onTopicsFailed) { + const topicName = $("#topic-name-modal-input").val(); + const partitions = $("#partitions-modal-input").val(); + const replications = $("#replications-modal-input").val(); + + const req = { + action: "createTopic", + topicName: topicName, + partitions: partitions, + replications: replications + }; + doPost(req, () => this.requestTopics(this.onTopicsLoaded, this.onTopicsFailed), onTopicsFailed); + } + + // TODO: add pagination here + deleteTopic(topicName, onTopicsDeleted, onTopicsFailed) { + const req = { + action: "deleteTopic", + key: topicName + }; + doPost(req, onTopicsDeleted, onTopicsFailed); + } + + deleteTopicRow(data) { + const topicName = window.currentContext.topicName; + $("#topics-table > tbody > tr > td:contains('" + topicName + "')").parent().remove() + } +} \ 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 index 50df67b1ccef4..d9f87034ab8dd 100644 --- a/extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js +++ b/extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js @@ -4,12 +4,58 @@ export function createTableItem(text) { }); } +export function createTableItemHtml(html) { + return $("
    ").append(createTableItemHtml( + collapseContent + .addClass("collapse-content")) + .attr("colspan", tableWidth)) + .attr("id", this.collapseId) + .addClass("collapse"); + } + + collapse() { + $("#" + this.collapseId).toggle(); + if (this.arrow.hasClass("icon-rotated")) { + this.arrow.removeClass("icon-rotated"); + } else { + this.arrow.addClass("icon-rotated"); + } + } +} + export function showItem(selector){ selector.addClass("shown") .removeClass("hidden"); diff --git a/extensions/kafka-client/ui/src/main/webapp/util/datetimeUtil.js b/extensions/kafka-client/ui/src/main/webapp/util/datetimeUtil.js new file mode 100644 index 0000000000000..384d693bde861 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/util/datetimeUtil.js @@ -0,0 +1,17 @@ +function addTrailingZero(data) { + if (data < 10) { + return "0" + data; + } + return data; +} + +export default function timestampToFormattedString(UNIX_timestamp) { + const a = new Date(UNIX_timestamp); + const year = a.getFullYear(); + const month = addTrailingZero(a.getMonth()); + const date = addTrailingZero(a.getDate()); + const hour = addTrailingZero(a.getHours()); + const min = addTrailingZero(a.getMinutes()); + const sec = addTrailingZero(a.getSeconds()); + return date + '/' + month + '/' + year + ' ' + hour + ':' + min + ':' + sec; +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/util/logo.js b/extensions/kafka-client/ui/src/main/webapp/util/logo.js new file mode 100644 index 0000000000000..2f40efa91fb37 --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/util/logo.js @@ -0,0 +1,8 @@ +import {faviconLogo, logo} from "../config.js" + +export function setLogo(){ + $("#navbar-logo") + .attr("src", logo); + $("#favicon") + .attr("href", faviconLogo); +} \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/util/spinner.js b/extensions/kafka-client/ui/src/main/webapp/util/spinner.js new file mode 100644 index 0000000000000..a5ca80594e44f --- /dev/null +++ b/extensions/kafka-client/ui/src/main/webapp/util/spinner.js @@ -0,0 +1,21 @@ +export function toggleSpinner(containerId, spinnerContainerId) { + const spinnerId = spinnerContainerId === undefined ? "#page-load-spinner" : "#" + spinnerContainerId; + const toggleContainerId = "#" + containerId; + let first; + let second; + + if ($(spinnerId).hasClass("shown")) { + first = toggleContainerId; + second = spinnerId; + } else { + second = toggleContainerId; + first = spinnerId; + } + + $(first) + .removeClass("hidden") + .addClass("shown"); + $(second) + .addClass("hidden") + .removeClass("shown"); +} \ No newline at end of file From 17505543c19c3799a1433e2b47160b86691536d5 Mon Sep 17 00:00:00 2001 From: Ozan Gunalp Date: Tue, 23 Aug 2022 19:37:47 +0300 Subject: [PATCH 3/3] Kafka dev ui as a dev console route minor fixes for UI --- bom/application/pom.xml | 5 - build-parent/pom.xml | 12 + extensions/kafka-client/deployment/pom.xml | 6 +- .../deployment/KafkaBuildTimeConfig.java | 7 - .../deployment/KafkaBuildTimeUiConfig.java | 28 - .../client/deployment/KafkaProcessor.java | 207 +------ .../main/resources/dev-static/js/config.js | 2 + .../main/resources/dev-static/js}/kafka_ui.js | 2 - .../js}/pages/accessControlListPage.js | 14 +- .../js}/pages/consumerGroupDetailsPage.js | 19 +- .../dev-static/js}/pages/consumerGroupPage.js | 3 +- .../dev-static/js}/pages/messagesPage.js | 0 .../dev-static/js}/pages/navigator.js | 0 .../dev-static/js}/pages/nodesPage.js | 7 +- .../dev-static/js}/pages/schemaPage.js | 0 .../dev-static/js}/pages/topicsPage.js | 0 .../dev-static/js}/util/contentManagement.js | 0 .../dev-static/js}/util/datetimeUtil.js | 0 .../resources/dev-static/js}/util/spinner.js | 0 .../main/resources/dev-static/js}/web/web.js | 0 .../resources/dev-templates/embedded.html | 2 +- .../resources/dev-templates/kafka-dev-ui.html | 513 +++++++++++++++++ extensions/kafka-client/pom.xml | 1 - extensions/kafka-client/runtime/pom.xml | 2 +- .../ui/AbstractHttpRequestHandler.java | 29 +- .../client/runtime/ui/KafkaUiHandler.java | 6 - .../client/runtime/ui/KafkaUiRecorder.java | 34 +- extensions/kafka-client/ui/pom.xml | 212 ------- .../kafka-client/ui/src/main/webapp/config.js | 4 - .../ui/src/main/webapp/favicon.ico | Bin 4286 -> 0 bytes .../ui/src/main/webapp/index.html | 532 ------------------ .../kafka-client/ui/src/main/webapp/logo.png | Bin 31710 -> 0 bytes .../main/webapp/quarkus_icon_rgb_reverse.svg | 1 - .../ui/src/main/webapp/util/logo.js | 8 - extensions/vertx-http/deployment/pom.xml | 59 ++ .../devmode/console/DevConsoleProcessor.java | 48 +- .../spi/DevConsoleWebjarBuildItem.java | 105 ++++ .../devmode/RuntimeDevConsoleRoute.java | 18 +- 38 files changed, 807 insertions(+), 1079 deletions(-) delete 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-static/js/config.js rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/kafka_ui.js (73%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/accessControlListPage.js (84%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/consumerGroupDetailsPage.js (85%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/consumerGroupPage.js (94%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/messagesPage.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/navigator.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/nodesPage.js (90%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/schemaPage.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/pages/topicsPage.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/util/contentManagement.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/util/datetimeUtil.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/util/spinner.js (100%) rename extensions/kafka-client/{ui/src/main/webapp => deployment/src/main/resources/dev-static/js}/web/web.js (100%) create mode 100644 extensions/kafka-client/deployment/src/main/resources/dev-templates/kafka-dev-ui.html delete mode 100644 extensions/kafka-client/ui/pom.xml delete mode 100644 extensions/kafka-client/ui/src/main/webapp/config.js delete mode 100644 extensions/kafka-client/ui/src/main/webapp/favicon.ico delete mode 100644 extensions/kafka-client/ui/src/main/webapp/index.html delete mode 100644 extensions/kafka-client/ui/src/main/webapp/logo.png delete mode 100644 extensions/kafka-client/ui/src/main/webapp/quarkus_icon_rgb_reverse.svg delete mode 100644 extensions/kafka-client/ui/src/main/webapp/util/logo.js create mode 100644 extensions/vertx-http/dev-console-spi/src/main/java/io/quarkus/devconsole/spi/DevConsoleWebjarBuildItem.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index d3902993aa765..83a9594b3528c 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1265,11 +1265,6 @@ quarkus-kafka-client-deployment ${project.version} - - io.quarkus - quarkus-kafka-client-ui - ${project.version} - io.quarkus quarkus-kafka-streams diff --git a/build-parent/pom.xml b/build-parent/pom.xml index e2d6e9c1b05eb..2a9d7f36089c1 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -134,6 +134,8 @@ 4.6.1 + 0.9.15 + 1.9.1 6.1.2 3.6.1 5.62.2 @@ -309,6 +311,16 @@ bootstrap ${webjar.bootstrap.version} + + org.webjars + bootstrap-multiselect + ${webjar.bootstrap-multiselect.version} + + + org.webjars.npm + bootstrap-icons + ${webjar.bootstrap-icons.version} + org.webjars font-awesome diff --git a/extensions/kafka-client/deployment/pom.xml b/extensions/kafka-client/deployment/pom.xml index 78ae0ae217423..a7da8dfa0277f 100644 --- a/extensions/kafka-client/deployment/pom.xml +++ b/extensions/kafka-client/deployment/pom.xml @@ -46,12 +46,8 @@ io.quarkus - quarkus-vertx-http-deployment + quarkus-vertx-http-dev-console-spi - - 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 ab52f4b2dc7a1..420d8bd85a453 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,6 +1,5 @@ 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; @@ -30,10 +29,4 @@ 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 deleted file mode 100644 index d8dfd5706a5d9..0000000000000 --- a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaBuildTimeUiConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -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 1557b9430add2..c29791d421569 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,21 +1,14 @@ 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; @@ -67,6 +60,7 @@ import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; +import io.quarkus.deployment.IsDevelopment; import io.quarkus.deployment.IsNormal; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; @@ -77,13 +71,11 @@ 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; @@ -92,6 +84,9 @@ import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.logging.LogCleanupFilterBuildItem; import io.quarkus.deployment.pkg.NativeConfig; +import io.quarkus.dev.spi.DevModeType; +import io.quarkus.devconsole.spi.DevConsoleRouteBuildItem; +import io.quarkus.devconsole.spi.DevConsoleWebjarBuildItem; import io.quarkus.kafka.client.runtime.*; import io.quarkus.kafka.client.runtime.KafkaRuntimeConfigProducer; import io.quarkus.kafka.client.runtime.ui.KafkaTopicClient; @@ -108,18 +103,7 @@ 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 { @@ -168,25 +152,11 @@ 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"; + private static final GACT DEVCONSOLE_WEBJAR_ARTIFACT_KEY = new GACT("io.quarkus", + "quarkus-kafka-client-deployment", null, "jar"); + private static final String DEVCONSOLE_WEBJAR_STATIC_RESOURCES_PATH = "dev-static/"; + public static final String KAFKA_ADMIN_PATH = "kafka-admin"; + public static final String KAFKA_RESOURCES_ROOT_PATH = "kafka-ui"; @BuildStep FeatureBuildItem feature() { @@ -535,153 +505,28 @@ public AdditionalBeanBuildItem kafkaClientBeans() { .build(); } - @BuildStep + @BuildStep(onlyIf = IsDevelopment.class) @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()); + BuildProducer routeProducer, + KafkaUiRecorder recorder) { + routeProducer.produce(DevConsoleRouteBuildItem.builder() + .method("POST") + .handler(recorder.kafkaControlHandler()) + .path(KAFKA_ADMIN_PATH) + .bodyHandlerRequired() + .build()); } - @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(onlyIf = IsDevelopment.class) + public DevConsoleWebjarBuildItem setupWebJar(LaunchModeBuildItem launchModeBuildItem) { + if (launchModeBuildItem.getDevModeType().orElse(null) != DevModeType.LOCAL) { + return null; } + return DevConsoleWebjarBuildItem.builder().artifactKey(DEVCONSOLE_WEBJAR_ARTIFACT_KEY) + .root(DEVCONSOLE_WEBJAR_STATIC_RESOURCES_PATH) + .routeRoot(KAFKA_RESOURCES_ROOT_PATH) + .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-static/js/config.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/config.js new file mode 100644 index 0000000000000..130b130828fbe --- /dev/null +++ b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/config.js @@ -0,0 +1,2 @@ +export const api = '/q/dev/io.quarkus.quarkus-kafka-client/kafka-admin'; +export const ui = 'kafka-ui'; \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/kafka_ui.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/kafka_ui.js similarity index 73% rename from extensions/kafka-client/ui/src/main/webapp/kafka_ui.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/kafka_ui.js index c07fdfd378217..36667854c1944 100644 --- a/extensions/kafka-client/ui/src/main/webapp/kafka_ui.js +++ b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/kafka_ui.js @@ -1,10 +1,8 @@ import Navigator from './pages/navigator.js' -import {setLogo} from "./util/logo.js"; const navigator = new Navigator(); $(document).ready( () => { - setLogo(); navigator.navigateToDefaultPage(); } ); diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/accessControlListPage.js similarity index 84% rename from extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/accessControlListPage.js index 2e396ec39874a..bf10bd05b5e66 100644 --- a/extensions/kafka-client/ui/src/main/webapp/pages/accessControlListPage.js +++ b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/accessControlListPage.js @@ -2,7 +2,7 @@ import {doPost, errorPopUp} from "../web/web.js"; import {createTableItem} from "../util/contentManagement.js"; import {toggleSpinner} from "../util/spinner.js"; -export default class AccessControlListPage{ +export default class AccessControlListPage { constructor(containerId) { this.containerId = containerId; Object.getOwnPropertyNames(AccessControlListPage.prototype).forEach((key) => { @@ -17,15 +17,15 @@ export default class AccessControlListPage{ const req = { action: "getAclInfo" }; + toggleSpinner(this.containerId); doPost(req, (data) => { - let that = this; - setTimeout(function () { - that.updateInfo(data); - toggleSpinner(that.containerId); + setTimeout(() => { + this.updateInfo(data); + toggleSpinner(this.containerId); }, 2000); }, data => { errorPopUp("Error getting Kafka ACL info: ", data); - }); + }); } updateInfo(data) { @@ -33,7 +33,7 @@ export default class AccessControlListPage{ $('#acluster-controller').html(data.broker); $('#acluster-acl').html(data.aclOperations); - const acls = data.entires; + const acls = data.entries; let aclTable = $('#acl-table tbody'); aclTable.empty(); for (let i = 0; i < acls.length; i++) { diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/consumerGroupDetailsPage.js similarity index 85% rename from extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/consumerGroupDetailsPage.js index cfd39d4d8be2d..3766588ce9292 100644 --- a/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupDetailsPage.js +++ b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/consumerGroupDetailsPage.js @@ -16,7 +16,7 @@ export default class ConsumerGroupDetailsPage { consumerGroupsTable.empty(); for (let i = 0; i < membersData.length; i++) { const d = membersData[i]; - const groupId = "group-" + window.crypto.randomUUID(); + const groupId = "group-" + d.memberId; let tableRow = $(""); let collapseRow; @@ -37,18 +37,17 @@ export default class ConsumerGroupDetailsPage { tableRow.append(createTableItem(d.host)); tableRow.append(createTableItem("" + new Set(d.partitions.map(x => x.partition)).size)); tableRow.append(createTableItem("" + d.partitions.map(x => x.lag).reduce((l, r) => l + r, 0))); - consumerGroupsTable.append(tableRow); if (d.partitions.length > 0) { const content = this.createConsumerGroupCollapseInfo(d); - tableRow - .addClass("pointer") - .click(collapseRow.collapse); - consumerGroupsTable.append( - collapseRow - .getCollapseContent(tableRow.children().length, content) - .addClass("no-hover") - ); + tableRow.addClass("pointer") + tableRow.click(() => collapseRow.collapse()); + consumerGroupsTable.append(tableRow); + consumerGroupsTable.append(collapseRow + .getCollapseContent(tableRow.children().length, content) + .addClass("no-hover")); + } else { + consumerGroupsTable.append(tableRow); } } } diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/consumerGroupPage.js similarity index 94% rename from extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/consumerGroupPage.js index 4a8500fab0c0e..51928af67a7e3 100644 --- a/extensions/kafka-client/ui/src/main/webapp/pages/consumerGroupPage.js +++ b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/consumerGroupPage.js @@ -40,8 +40,7 @@ export default class ConsumerGroupPage { tableRow.append(createTableItem(d.protocol)); tableRow.append(createTableItem(d.members.length)); tableRow.append(createTableItem(d.lag)); - const self = this; - tableRow.click(() => self.navigator.navigateTo(pages.CONSUMER_GROUPS_DETAILS, [d.name, d.members])); + tableRow.click(() => this.navigator.navigateTo(pages.CONSUMER_GROUPS_DETAILS, [d.name, d.members])); consumerGroupsTable.append(tableRow); } } diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/messagesPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/messagesPage.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/pages/messagesPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/messagesPage.js diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/navigator.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/navigator.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/pages/navigator.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/navigator.js diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/nodesPage.js similarity index 90% rename from extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/nodesPage.js index ad8675344e22b..94b2b1e6a270d 100644 --- a/extensions/kafka-client/ui/src/main/webapp/pages/nodesPage.js +++ b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/nodesPage.js @@ -17,10 +17,9 @@ export default class NodesPage { action: "getInfo" }; doPost(req, (data) => { - let that = this; - setTimeout(function () { - that.updateInfo(data); - toggleSpinner(that.containerId); + setTimeout(() => { + this.updateInfo(data); + toggleSpinner(this.containerId); }, 2000); }, data => { errorPopUp("Error getting Kafka info: ", data); diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/schemaPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/schemaPage.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/pages/schemaPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/schemaPage.js diff --git a/extensions/kafka-client/ui/src/main/webapp/pages/topicsPage.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/topicsPage.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/pages/topicsPage.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/pages/topicsPage.js diff --git a/extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/util/contentManagement.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/util/contentManagement.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/util/contentManagement.js diff --git a/extensions/kafka-client/ui/src/main/webapp/util/datetimeUtil.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/util/datetimeUtil.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/util/datetimeUtil.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/util/datetimeUtil.js diff --git a/extensions/kafka-client/ui/src/main/webapp/util/spinner.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/util/spinner.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/util/spinner.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/util/spinner.js diff --git a/extensions/kafka-client/ui/src/main/webapp/web/web.js b/extensions/kafka-client/deployment/src/main/resources/dev-static/js/web/web.js similarity index 100% rename from extensions/kafka-client/ui/src/main/webapp/web/web.js rename to extensions/kafka-client/deployment/src/main/resources/dev-static/js/web/web.js 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 index 312a6c5268e02..dac4cf81f4150 100644 --- a/extensions/kafka-client/deployment/src/main/resources/dev-templates/embedded.html +++ b/extensions/kafka-client/deployment/src/main/resources/dev-templates/embedded.html @@ -1,3 +1,3 @@ - + Kafka UI diff --git a/extensions/kafka-client/deployment/src/main/resources/dev-templates/kafka-dev-ui.html b/extensions/kafka-client/deployment/src/main/resources/dev-templates/kafka-dev-ui.html new file mode 100644 index 0000000000000..f07f33984441f --- /dev/null +++ b/extensions/kafka-client/deployment/src/main/resources/dev-templates/kafka-dev-ui.html @@ -0,0 +1,513 @@ +{#include main fluid=true} +{#style} +html { +min-height: 90vh; +min-width: 100vh; +} + +body { +min-height: 90vh; +min-width: 100vh; +} + +.row-holder { +padding: 0; +margin: 0; + +} + +.row:after { +content: ""; +display: table; +clear: both; +} + +.content-holder { +height: auto; +min-height: 90vh; +} + +.link { +background: none; +border: none; +} + +.top-margin { +margin-top: 1em; +} + +.left-margin { +margin-left: 1em; +} + +.left-padding { +padding-left: 1em; +} + +.shown { +display: flex; +height: auto; +min-width: 100%; +} + +.text-shown { +display: inline; +} + +.hidden { +display: none +} + +.nav-item:hover > .nav-row > a { +background-color: #005fff; +color: #e9ecef; +} + +.nav-item:hover > .nav-row > i { +background-color: #005fff; +color: #e9ecef; +} + +#navbar-list > .nav-item:hover { +background-color: #005fff; +color: #e9ecef; +} + +.table-hover:hover { +cursor: pointer; +} + +.multiselect-container > li > a > label { +padding-left: 15px !important; +} + +.page { +min-height: calc(100vh - 135px); +} + +.table-hover:hover { +cursor: pointer; +} + +.pointer { +cursor: pointer; +} + +.no-hover { +background-color: white; +cursor: default; +} + +.no-hover:hover { +background-color: white !important; +cursor: default; +} + +.icon-rotated { +transform: rotate(90deg); +} + +.navbar-brand img { +border-right: 1px solid darkgrey; +padding-right: 10px; +margin-right: 5px; +} + +.navbar-brand { +padding: 0; +margin: 0; +} + +#nav-menu-panel { +padding: 0px; +} + +.float-plus-btn { +position: fixed; +bottom: 60px; +right: 60px; +border-radius: 100%; +height: 50px; +width: 50px; +} + +.breadcrumb-item::before { +float: left; +padding-right: 0.5rem; +color: #007bff; +content: "〉"; +} + +.breadcrumb-item + .breadcrumb-item::before { +float: left; +padding-right: 0.5rem; +color: #007bff; +content: "〉"; +} + +.breadcrumb { +background-color: #343a40; +margin-bottom: 0; +padding: 0 0 0 5px; +} + +.bi-trash-fill:hover { +color: #007bff; +} + +.collapse-content { +max-width: 1200px; +} + +.thead-multiselect { +background-color: #343a40; +color: white; +border: 0px; +font-weight: bold; +} + +.thead-text { +color: white; +} + +#msg-table-holder { +min-width: 100%; +} +{/style} +{#styleref} + + +{/styleref} +{#scriptref} + + +{/scriptref} +{#title}Kafka Dev UI{/title} +{#body} +
    + + + + + + +
    +
    +
    +
    ").append(html); +} + +export function createTableHead(title) { + return $("") + .attr("scope", "col") + .text(title); +} + export function createIcon(iconClass) { return $("") .addClass("bi") .addClass(iconClass); } +export class CollapseRow { + constructor(collapseId) { + this.collapseId = collapseId; + const chevronIcon = createIcon("bi-chevron-right") + .addClass("rotate-icon"); + this.arrow = $("
    ") + .addClass("d-flex") + .addClass("justify-content-center") + .append(chevronIcon); + + Object.getOwnPropertyNames(CollapseRow.prototype).forEach((key) => { + if (key !== 'constructor') { + this[key] = this[key].bind(this); + } + }); + } + + getCollapseContent(tableWidth, collapseContent) { + return $("
    + + + + + + + + + + + +
    Topic NameIdPartitions countNumber of msg
    + + + +

    +
    +
    +
    + + + + + + + + + + + + + + +
    Offset + + Timestamp KeyValue
    +
    + +
    +
    + + +
    +
    +
    + + + + + + + + + + + + + +
    StateIdCoordinatorProtocolMembersLag(Sum)
    +
    +
    +
    +
    + + + + + + + + + + + + +
    Member IDHostPartitionsLag(Sum)
    +
    +
    +
    +
    +
    + Kafka cluster id: 
    + Controller node (broker): 
    + ACL operations: 
    +
    +
    +

    Cluster nodes

    +
    + + + + + + + + + + +
    IdHostPort
    +
    +
    + + + +{/body} +{/include} \ No newline at end of file diff --git a/extensions/kafka-client/pom.xml b/extensions/kafka-client/pom.xml index dd7448eb06106..a0e0885d2b170 100644 --- a/extensions/kafka-client/pom.xml +++ b/extensions/kafka-client/pom.xml @@ -15,7 +15,6 @@ pom - ui deployment runtime diff --git a/extensions/kafka-client/runtime/pom.xml b/extensions/kafka-client/runtime/pom.xml index 0e037445a128e..1d237acadf213 100644 --- a/extensions/kafka-client/runtime/pom.xml +++ b/extensions/kafka-client/runtime/pom.xml @@ -61,7 +61,7 @@ io.quarkus - quarkus-vertx-http + quarkus-vertx-http-dev-console-runtime-spi 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 index bc83093041b33..ede6bc54f21f8 100644 --- 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 @@ -2,24 +2,15 @@ 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; + public AbstractHttpRequestHandler() { this.currentManagedContext = Arc.container().requestContext(); this.currentManagedContextTerminationHandler = e -> currentManagedContext.terminate(); } @@ -29,7 +20,7 @@ public AbstractHttpRequestHandler(CurrentIdentityAssociation currentIdentityAsso public void handle(final RoutingContext ctx) { if (currentManagedContext.isActive()) { - handleWithIdentity(ctx); + doHandle(ctx); } else { currentManagedContext.activate(); @@ -39,7 +30,7 @@ public void handle(final RoutingContext ctx) { .closeHandler(currentManagedContextTerminationHandler); try { - handleWithIdentity(ctx); + doHandle(ctx); } catch (Throwable t) { currentManagedContext.terminate(); throw t; @@ -70,20 +61,6 @@ public void doHandle(RoutingContext ctx) { } } - 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); 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 index 99d5b9d550813..8b7d916de765b 100644 --- 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 @@ -13,17 +13,11 @@ import io.quarkus.kafka.client.runtime.ui.model.request.KafkaMessageCreateRequest; import io.quarkus.kafka.client.runtime.ui.model.request.KafkaMessagesRequest; import io.quarkus.kafka.client.runtime.ui.model.request.KafkaOffsetRequest; -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) { 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 index 21d89717ffcbc..90afe8521cc11 100644 --- 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 @@ -1,18 +1,7 @@ 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; /** @@ -22,28 +11,7 @@ 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); + return new KafkaUiHandler(); } - 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/ui/pom.xml b/extensions/kafka-client/ui/pom.xml deleted file mode 100644 index 31960a9ee918e..0000000000000 --- a/extensions/kafka-client/ui/pom.xml +++ /dev/null @@ -1,212 +0,0 @@ - - - 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/config.js b/extensions/kafka-client/ui/src/main/webapp/config.js deleted file mode 100644 index 1c141c4c1e97c..0000000000000 --- a/extensions/kafka-client/ui/src/main/webapp/config.js +++ /dev/null @@ -1,4 +0,0 @@ -export const api = '/kafka-admin'; -export const logo = 'quarkus_icon_rgb_reverse.svg'; -export const faviconLogo = 'favicon.ico'; -export const ui = ''; \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/favicon.ico b/extensions/kafka-client/ui/src/main/webapp/favicon.ico deleted file mode 100644 index b4ef4208a6f489de1c17dd3791a3691ae77dee0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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
    -
    - -
    -
    - - -
    -
    -
    - - - - - - - - - - - - - -
    StateIdCoordinatorProtocolMembersLag(Sum)
    -
    -
    -
    -
    - - - - - - - - - - - - -
    Member IDHostPartitionsLag(Sum)
    -
    -
    -
    -
    -
    - 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 deleted file mode 100644 index 6a1626104eb98f63a7dd134135784fbb0eca1d0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31710 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 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 deleted file mode 100644 index 1969e1e886af3..0000000000000 --- a/extensions/kafka-client/ui/src/main/webapp/quarkus_icon_rgb_reverse.svg +++ /dev/null @@ -1 +0,0 @@ -quarkus_icon_rgb_1024px_reverse \ No newline at end of file diff --git a/extensions/kafka-client/ui/src/main/webapp/util/logo.js b/extensions/kafka-client/ui/src/main/webapp/util/logo.js deleted file mode 100644 index 2f40efa91fb37..0000000000000 --- a/extensions/kafka-client/ui/src/main/webapp/util/logo.js +++ /dev/null @@ -1,8 +0,0 @@ -import {faviconLogo, logo} from "../config.js" - -export function setLogo(){ - $("#navbar-logo") - .attr("src", logo); - $("#favicon") - .attr("href", faviconLogo); -} \ No newline at end of file diff --git a/extensions/vertx-http/deployment/pom.xml b/extensions/vertx-http/deployment/pom.xml index f73b67dd3c39e..a5154ddd7b939 100644 --- a/extensions/vertx-http/deployment/pom.xml +++ b/extensions/vertx-http/deployment/pom.xml @@ -52,6 +52,16 @@ bootstrap provided
    + + org.webjars + bootstrap-multiselect + provided + + + org.webjars.npm + bootstrap-icons + provided + org.webjars font-awesome @@ -196,6 +206,55 @@ + + + org.webjars + bootstrap-multiselect + ${webjar.bootstrap-multiselect.version} + jar + true + ${project.build.directory}/classes/dev-static/js/ + **/bootstrap-multiselect.js + + + + + + org.webjars + bootstrap-multiselect + ${webjar.bootstrap-multiselect.version} + jar + true + ${project.build.directory}/classes/dev-static/css/ + **/bootstrap-multiselect.css + + + + + + org.webjars.npm + bootstrap-icons + ${webjar.bootstrap-icons.version} + jar + true + ${project.build.directory}/classes/dev-static/css/ + **/font/bootstrap-icons.css + + + + + + org.webjars.npm + bootstrap-icons + ${webjar.bootstrap-icons.version} + jar + true + ${project.build.directory}/classes/dev-static/css/fonts/ + **/font/fonts/ + + + + org.webjars diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java index 0943aeda594d1..6c34e448daabe 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java @@ -64,6 +64,7 @@ import io.quarkus.devconsole.spi.DevConsoleRouteBuildItem; import io.quarkus.devconsole.spi.DevConsoleRuntimeTemplateInfoBuildItem; import io.quarkus.devconsole.spi.DevConsoleTemplateInfoBuildItem; +import io.quarkus.devconsole.spi.DevConsoleWebjarBuildItem; import io.quarkus.maven.dependency.ArtifactKey; import io.quarkus.maven.dependency.GACT; import io.quarkus.netty.runtime.virtual.VirtualChannel; @@ -88,6 +89,7 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.TemplateHtmlBuilder; import io.quarkus.utilities.OS; +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; @@ -387,6 +389,46 @@ public WebJarBuildItem setupWebJar( .build(); } + @BuildStep(onlyIf = IsDevelopment.class) + public void setupDevConsoleWebjar( + List devConsoleWebjarBuildItems, + BuildProducer webJarBuildItemBuildProducer, + LaunchModeBuildItem launchModeBuildItem) { + if (launchModeBuildItem.getDevModeType().orElse(null) != DevModeType.LOCAL) { + return; + } + for (DevConsoleWebjarBuildItem devConsoleWebjar : devConsoleWebjarBuildItems) { + webJarBuildItemBuildProducer.produce(WebJarBuildItem.builder() + .artifactKey(devConsoleWebjar.getArtifactKey()) + .root(devConsoleWebjar.getRoot()) + .useDefaultQuarkusBranding(devConsoleWebjar.getUseDefaultQuarkusBranding()) + .onlyCopyNonArtifactFiles(devConsoleWebjar.getOnlyCopyNonArtifactFiles()) + .build()); + } + } + + @Record(ExecutionTime.RUNTIME_INIT) + @BuildStep(onlyIf = IsDevelopment.class) + public void setupDevConsoleRoutes( + List devConsoleWebjarBuildItems, + DevConsoleRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, + ShutdownContextBuildItem shutdownContext, + BuildProducer routeBuildItemBuildProducer, + WebJarResultsBuildItem webJarResultsBuildItem) { + + for (DevConsoleWebjarBuildItem webjarBuildItem : devConsoleWebjarBuildItems) { + WebJarResultsBuildItem.WebJarResult result = webJarResultsBuildItem.byArtifactKey(webjarBuildItem.getArtifactKey()); + if (result == null) { + continue; + } + routeBuildItemBuildProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .route("dev/" + webjarBuildItem.getRouteRoot() + "/*") + .handler(recorder.fileSystemStaticHandler(result.getWebRootConfigurations(), shutdownContext)) + .build()); + } + } + @BuildStep(onlyIf = { IsDevelopment.class }) public DevConsoleTemplateInfoBuildItem config(List serviceDescriptions) { return new DevConsoleTemplateInfoBuildItem("devServices", serviceDescriptions); @@ -404,7 +446,8 @@ public void setupDevConsoleRoutes( ShutdownContextBuildItem shutdownContext, BuildProducer routeBuildItemBuildProducer, WebJarResultsBuildItem webJarResultsBuildItem, - CurateOutcomeBuildItem curateOutcomeBuildItem) { + CurateOutcomeBuildItem curateOutcomeBuildItem, + BodyHandlerBuildItem bodyHandlerBuildItem) { WebJarResultsBuildItem.WebJarResult result = webJarResultsBuildItem.byArtifactKey(DEVCONSOLE_WEBJAR_ARTIFACT_KEY); @@ -432,7 +475,8 @@ public void setupDevConsoleRoutes( NonApplicationRootPathBuildItem.Builder builder = nonApplicationRootPathBuildItem.routeBuilder() .routeFunction( "dev/" + groupAndArtifact.getKey() + "." + groupAndArtifact.getValue() + "/" + i.getPath(), - new RuntimeDevConsoleRoute(i.getMethod())); + new RuntimeDevConsoleRoute(i.getMethod(), + i.isBodyHandlerRequired() ? bodyHandlerBuildItem.getHandler() : null)); if (i.isBlockingHandler()) { builder.blockingRoute(); } diff --git a/extensions/vertx-http/dev-console-spi/src/main/java/io/quarkus/devconsole/spi/DevConsoleWebjarBuildItem.java b/extensions/vertx-http/dev-console-spi/src/main/java/io/quarkus/devconsole/spi/DevConsoleWebjarBuildItem.java new file mode 100644 index 0000000000000..01d16ceea3e3c --- /dev/null +++ b/extensions/vertx-http/dev-console-spi/src/main/java/io/quarkus/devconsole/spi/DevConsoleWebjarBuildItem.java @@ -0,0 +1,105 @@ +package io.quarkus.devconsole.spi; + +import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.maven.dependency.GACT; + +public final class DevConsoleWebjarBuildItem extends MultiBuildItem { + /** + * ArtifactKey pointing to the web jar. Has to be one of the applications dependencies. + */ + private final GACT artifactKey; + + /** + * Root inside the webJar starting from which resources are unpacked. + */ + private final String root; + + /** + * Only copy resources of the webjar which are either user overridden, or contain variables. + */ + private final boolean onlyCopyNonArtifactFiles; + + /** + * Defines whether Quarkus can override resources of the webjar with Quarkus internal files. + */ + private final boolean useDefaultQuarkusBranding; + + /** + * The root of the route to expose resources of the webjar + */ + private final String routeRoot; + + private DevConsoleWebjarBuildItem(Builder builder) { + this.artifactKey = builder.artifactKey; + this.root = builder.root; + this.useDefaultQuarkusBranding = builder.useDefaultQuarkusBranding; + this.onlyCopyNonArtifactFiles = builder.onlyCopyNonArtifactFiles; + this.routeRoot = builder.routeRoot; + } + + public GACT getArtifactKey() { + return artifactKey; + } + + public String getRoot() { + return root; + } + + public boolean getUseDefaultQuarkusBranding() { + return useDefaultQuarkusBranding; + } + + public boolean getOnlyCopyNonArtifactFiles() { + return onlyCopyNonArtifactFiles; + } + + public String getRouteRoot() { + return routeRoot; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private GACT artifactKey; + private String root; + private boolean useDefaultQuarkusBranding = true; + private boolean onlyCopyNonArtifactFiles = true; + private String routeRoot; + + public Builder artifactKey(GACT artifactKey) { + this.artifactKey = artifactKey; + return this; + } + + public Builder root(String root) { + this.root = root; + + if (this.root != null && this.root.startsWith("/")) { + this.root = this.root.substring(1); + } + + return this; + } + + public Builder routeRoot(String route) { + this.routeRoot = route; + return this; + } + + public Builder useDefaultQuarkusBranding(boolean useDefaultQuarkusBranding) { + this.useDefaultQuarkusBranding = useDefaultQuarkusBranding; + return this; + } + + public Builder onlyCopyNonArtifactFiles(boolean onlyCopyNonArtifactFiles) { + this.onlyCopyNonArtifactFiles = onlyCopyNonArtifactFiles; + return this; + } + + public DevConsoleWebjarBuildItem build() { + return new DevConsoleWebjarBuildItem(this); + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/RuntimeDevConsoleRoute.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/RuntimeDevConsoleRoute.java index ff5a05a705691..d3adff64c5e0a 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/RuntimeDevConsoleRoute.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/RuntimeDevConsoleRoute.java @@ -2,24 +2,37 @@ import java.util.function.Consumer; +import io.vertx.core.Handler; import io.vertx.core.http.HttpMethod; import io.vertx.ext.web.Route; +import io.vertx.ext.web.RoutingContext; public class RuntimeDevConsoleRoute implements Consumer { private String method; + private Handler bodyHandler; + public RuntimeDevConsoleRoute() { } - public RuntimeDevConsoleRoute(String method) { + public RuntimeDevConsoleRoute(String method, Handler hasBodyHandler) { this.method = method; + this.bodyHandler = hasBodyHandler; } public String getMethod() { return method; } + public Handler getBodyHandler() { + return bodyHandler; + } + + public void setBodyHandler(Handler bodyHandler) { + this.bodyHandler = bodyHandler; + } + public RuntimeDevConsoleRoute setMethod(String method) { this.method = method; return this; @@ -29,5 +42,8 @@ public RuntimeDevConsoleRoute setMethod(String method) { public void accept(Route route) { route.method(HttpMethod.valueOf(method)) .order(-100); + if (bodyHandler != null) { + route.handler(bodyHandler); + } } }