From 2b0f284441ba90323951dad4be0738155660d8dd Mon Sep 17 00:00:00 2001 From: tchiotludo Date: Fri, 24 Aug 2018 15:50:55 +0200 Subject: [PATCH] Initial commit --- .gitignore | 36 +++ .yarnrc | 1 + Dockerfile | 13 + build.gradle | 91 +++++++ conf/application.conf | 20 ++ conf/assets.conf | 32 +++ conf/logback.xml | 39 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54413 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 172 +++++++++++++ gradlew.bat | 84 +++++++ package.json | 28 +++ public/favicon.ico | Bin 0 -> 1150 bytes public/group.ftl | 133 +++++++++++ public/groupList.ftl | 11 + public/includes/functions.ftl | 8 + public/includes/group.ftl | 63 +++++ public/includes/node.ftl | 7 + public/includes/template.ftl | 78 ++++++ public/static/_init.scss | 6 + public/static/_variables.scss | 150 ++++++++++++ public/static/img/icon.png | Bin 0 -> 30966 bytes public/static/img/icon.svg | 93 ++++++++ public/static/img/logo.svg | 85 +++++++ public/static/template.js | 85 +++++++ public/static/template.scss | 223 +++++++++++++++++ public/static/vendor.scss | 7 + public/topic.ftl | 127 ++++++++++ public/topicList.ftl | 82 +++++++ settings.gradle | 2 + src/main/java/org/kafkahq/App.java | 64 +++++ .../controllers/AbstractController.java | 17 ++ .../kafkahq/controllers/GroupController.java | 53 +++++ .../kafkahq/controllers/TopicController.java | 102 ++++++++ src/main/java/org/kafkahq/models/Cluster.java | 40 ++++ .../java/org/kafkahq/models/Consumer.java | 88 +++++++ .../org/kafkahq/models/ConsumerGroup.java | 146 ++++++++++++ src/main/java/org/kafkahq/models/LogDir.java | 76 ++++++ src/main/java/org/kafkahq/models/Node.java | 62 +++++ .../java/org/kafkahq/models/Partition.java | 94 ++++++++ src/main/java/org/kafkahq/models/Topic.java | 101 ++++++++ .../org/kafkahq/models/TopicPartition.java | 87 +++++++ .../java/org/kafkahq/modules/KafkaModule.java | 94 ++++++++ .../org/kafkahq/modules/KafkaWrapper.java | 225 ++++++++++++++++++ .../repositories/AbstractRepository.java | 11 + .../repositories/ClusterRepository.java | 23 ++ .../repositories/ConsumerGroupRepository.java | 84 +++++++ .../repositories/LogDirRepository.java | 45 ++++ .../repositories/RecordRepository.java | 166 +++++++++++++ .../kafkahq/repositories/TopicRepository.java | 84 +++++++ .../response/ResultStatusResponse.java | 6 + src/main/java/org/kafkahq/utils/Debug.java | 58 +++++ yarn.lock | 27 +++ 53 files changed, 3434 insertions(+) create mode 100644 .gitignore create mode 100644 .yarnrc create mode 100644 Dockerfile create mode 100644 build.gradle create mode 100644 conf/application.conf create mode 100644 conf/assets.conf create mode 100644 conf/logback.xml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 public/group.ftl create mode 100644 public/groupList.ftl create mode 100644 public/includes/functions.ftl create mode 100644 public/includes/group.ftl create mode 100644 public/includes/node.ftl create mode 100644 public/includes/template.ftl create mode 100644 public/static/_init.scss create mode 100644 public/static/_variables.scss create mode 100644 public/static/img/icon.png create mode 100644 public/static/img/icon.svg create mode 100644 public/static/img/logo.svg create mode 100644 public/static/template.js create mode 100644 public/static/template.scss create mode 100644 public/static/vendor.scss create mode 100644 public/topic.ftl create mode 100644 public/topicList.ftl create mode 100644 settings.gradle create mode 100644 src/main/java/org/kafkahq/App.java create mode 100644 src/main/java/org/kafkahq/controllers/AbstractController.java create mode 100644 src/main/java/org/kafkahq/controllers/GroupController.java create mode 100644 src/main/java/org/kafkahq/controllers/TopicController.java create mode 100644 src/main/java/org/kafkahq/models/Cluster.java create mode 100644 src/main/java/org/kafkahq/models/Consumer.java create mode 100644 src/main/java/org/kafkahq/models/ConsumerGroup.java create mode 100644 src/main/java/org/kafkahq/models/LogDir.java create mode 100644 src/main/java/org/kafkahq/models/Node.java create mode 100644 src/main/java/org/kafkahq/models/Partition.java create mode 100644 src/main/java/org/kafkahq/models/Topic.java create mode 100644 src/main/java/org/kafkahq/models/TopicPartition.java create mode 100644 src/main/java/org/kafkahq/modules/KafkaModule.java create mode 100644 src/main/java/org/kafkahq/modules/KafkaWrapper.java create mode 100644 src/main/java/org/kafkahq/repositories/AbstractRepository.java create mode 100644 src/main/java/org/kafkahq/repositories/ClusterRepository.java create mode 100644 src/main/java/org/kafkahq/repositories/ConsumerGroupRepository.java create mode 100644 src/main/java/org/kafkahq/repositories/LogDirRepository.java create mode 100644 src/main/java/org/kafkahq/repositories/RecordRepository.java create mode 100644 src/main/java/org/kafkahq/repositories/TopicRepository.java create mode 100644 src/main/java/org/kafkahq/response/ResultStatusResponse.java create mode 100644 src/main/java/org/kafkahq/utils/Debug.java create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..eac51fe0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +### Java +*.class +*.log +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* + +### Gradle +.gradle/* +build/* +!gradle-wrapper.jar +.gradletasknamecache + +### Assets +public/vendor + +### Ide ### +.idea/* +.vscode/* +.project/* +.settings/* +out/* + +### Log ### +.*.log +.*.log.[0-9] +*.log.[0-9] +logs/* + +### Kafka HQ ### +conf/*.dev.conf \ No newline at end of file diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000..0842dd9c5 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +--modules-folder public/vendor \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..8bf7faf07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM openjdk:8-jdk as builder +COPY . /app +WORKDIR /app +RUN echo -e 'http://dl-cdn.alpinelinux.org/alpine/edge/main\nhttp://dl-cdn.alpinelinux.org/alpine/edge/community\nhttp://dl-cdn.alpinelinux.org/alpine/edge/testing' > /etc/apk/repositories && \ + apk add --no-cache yarn && \ + yarn install && \ + ./gradlew jar + + +FROM openjdk:8-jre +WORKDIR /app +COPY --from=builder /app/build/libs/kafkahq-*.jar /app/kafkahq.jar +CMD ["/usr/bin/java", "-jar", "/app/kafkahq.jar", "prod"] \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..1a8fad7bc --- /dev/null +++ b/build.gradle @@ -0,0 +1,91 @@ +buildscript { + ext { + joobyVersion = "1.5.1" + } + + repositories { + mavenLocal() + mavenCentral() + } + + dependencies { + classpath "com.google.gradle:osdetector-gradle-plugin:1.4.0" + classpath "io.spring.gradle:dependency-management-plugin:1.0.3.RELEASE" + classpath "org.jooby:jooby-gradle-plugin:$joobyVersion" + } +} + +apply plugin: "io.spring.dependency-management" +apply plugin: "com.google.osdetector" +apply plugin: "application" +apply plugin: "jooby" +apply plugin: "idea" + +group 'org.kafkahq' +version '0.1' +mainClassName = "org.kafkahq.App" +sourceCompatibility = 1.8 + +repositories { + mavenLocal() + mavenCentral() +} + +dependencyManagement { + imports { + mavenBom "org.jooby:jooby-bom:$joobyVersion" + } +} + +idea { + module { + downloadJavadoc = false + downloadSources = true + } +} + +joobyRun { + includes = ['**/*.class', '**/*.conf', '**/*.properties', '**/*.ftl', '**/*.xml'] +} + + +dependencies { + // jooby + compile "org.jooby:jooby-netty" + compile "io.netty:netty-transport-native-epoll:${dependencyManagement.importedProperties['netty.version']}:${osdetector.classifier.contains('linux') ? 'linux-x86_64' : ''}" + compile "io.netty:netty-tcnative-boringssl-static:${dependencyManagement.importedProperties['boringssl.version']}" + compile group: 'org.jooby', name: 'jooby-assets', version: joobyVersion + compile group: 'org.jooby', name: 'jooby-assets-sass', version: joobyVersion + compile group: 'org.jooby', name: 'jooby-livereload', version: joobyVersion + // compile group: 'org.jooby', name: 'jooby-assets-auto-prefixer', version: joobyVersion + // compile group: 'org.jooby', name: 'jooby-assets-clean-css', version: joobyVersion + // compile group: 'org.jooby', name: 'jooby-assets-uglify', version: joobyVersion + compile group: 'org.jooby', name: 'jooby-jackson', version: joobyVersion + compile group: 'org.jooby', name: 'jooby-ftl', version: joobyVersion + compile group: 'org.jooby', name: 'jooby-whoops', version: joobyVersion + + // kafka + compile group: 'org.apache.kafka', name: 'kafka-clients', version: '2.0.+' + + // debug + compile group: 'com.google.code.gson', name: 'gson', version: '2.8.+' + compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.+' + + // test + testCompile "junit:junit:4.12" + testCompile "io.rest-assured:rest-assured:3.1.0" +} + +sourceSets.main.resources { + srcDirs = ["conf", "public"] +} + +jar { + manifest { + attributes "Main-Class": mainClassName + } + + from { + configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } + } +} \ No newline at end of file diff --git a/conf/application.conf b/conf/application.conf new file mode 100644 index 000000000..d6053fba9 --- /dev/null +++ b/conf/application.conf @@ -0,0 +1,20 @@ +application { + name: Kafka HQ + charset: UTF-8 + port: 8080 + port: ${?APPLICATION_PORT} +} + +sass { + dev { + sourcemap: inline + } + + dist { + style: compressed + } +} + + +freemarker { +} \ No newline at end of file diff --git a/conf/assets.conf b/conf/assets.conf new file mode 100644 index 000000000..d9e059427 --- /dev/null +++ b/conf/assets.conf @@ -0,0 +1,32 @@ +assets { + fileset { + vendor: [ + static/vendor.scss + + vendor/jquery/dist/jquery.js + vendor/popper.js/dist/umd/popper.js + vendor/bootstrap/dist/js/bootstrap.js + vendor/sweetalert2/dist/sweetalert2.all.js + vendor/turbolinks/dist/turbolinks.js + ] + + template: [ + static/template.scss, + static/template.js + ] + } + + pipeline { + dev: [sass] + dist: [sass] + } + + sass { + dev { + sourcemap: inline + } + dist { + style: compressed + } + } +} \ No newline at end of file diff --git a/conf/logback.xml b/conf/logback.xml new file mode 100644 index 000000000..21ec88f64 --- /dev/null +++ b/conf/logback.xml @@ -0,0 +1,39 @@ + + + + System.out + true + + ERROR + DENY + + + WARN + DENY + + + %d{ISO8601} %highlight(%5.5level) %magenta(%-10.10thread) %cyan(%-26.26logger{26}) %msg%n + + + + + System.err + true + + WARN + + + %d{ISO8601} %highlight(%5.5level) %magenta(%-10.10thread) %cyan(%-26.26logger{26}) %msg%n + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..0d4a9516871afd710a9d84d89e31ba77745607bd GIT binary patch literal 54413 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..bd24854fe --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..cccdd3d51 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/package.json b/package.json new file mode 100644 index 000000000..6519dbcec --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "kafkahq", + "version": "1.0.0", + "description": "", + "main": "index.js", + "dependencies": { + "bootstrap": "^4.1.3", + "font-awesome": "^4.7.0", + "jquery": "^3.3.1", + "popper.js": "^1.14.4", + "sweetalert2": "^7.28.0", + "turbolinks": "^5.2.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tchiotludo/kafkahq.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/tchiotludo/kafkahq/issues" + }, + "homepage": "https://github.com/tchiotludo/kafkahq#readme" +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..52ab0a55dcdb613730376bca875b0a593f610875 GIT binary patch literal 1150 zcmbW$Ur19?90%|-XPH4b0$a^u8~xjZ&>fl-N)Uue+Hh7YEtZPnp9<+8J&F<-Z7Y{X zAz6yLL|LtWHb}GF=I-?BwuGs3=|g`YdI@?7B-i)1%_8YRyW?{Y=brOD=j@(y857|r zkucm9tTLXl4U91r;1QlgpQB(5ZL7smk@}LGT47{rWke#%O%PA@}Up$5dT ztRbi$9D;|i2l_w(E6Hb}{|lhGpMw-sa2mE}rkdG&ctP}ieqJ07hyNNE?KD6A=V};- z{jeFHLN_NjSdZkirCjz+Txyw_)3Uqm3}c84fndR%;Ox$(>G!*gfnZ)*G-pF*3uHqt zC%q;;nD@Y5*z?w|b?}q7rbE=1^5Rpj(T;P1?{JmZUzp!)pGs4je8|xJ!_dPijLuB1 z$*yUA^;ORcodrW~SA}Q1Om2Hg`M6^azO#%UYc3JSTXO1c3mHn&1>};T8yZ#Xu5Qdf z(_5kKcX=!yJ|>sAh4SN0&wW}u zUi*63W&QMdeW_b0);T?GvAnp*HPTTd_%+o&|4EIxZ#G?NdWYN)jKL#}HIS9wJgDg! z_);Nw8*~njy_6rj$U1r>>#v&#l;513;jRaQd+U6GV&wCXqrKk&bRMj`Qw*|PeOG^x z&GDvk)Z1_`G?#3i3Q_(ySz;|q-*$ucuL=a%2XdhE(5jXjo*dwA%V^UnxAa0E+W$@PyZP8|RM literal 0 HcmV?d00001 diff --git a/public/group.ftl b/public/group.ftl new file mode 100644 index 000000000..79af7167c --- /dev/null +++ b/public/group.ftl @@ -0,0 +1,133 @@ +<#-- @ftlvariable name="clusterId" type="java.lang.String" --> +<#-- @ftlvariable name="group" type="org.kafkahq.models.ConsumerGroup" --> +<#-- @ftlvariable name="tab" type="java.lang.String" --> + +<#import "/includes/template.ftl" as template> + +<@template.header "Consumer Group: " + group.getId(), "group" /> + + + + +<@template.footer/> + diff --git a/public/groupList.ftl b/public/groupList.ftl new file mode 100644 index 000000000..a7647ca1f --- /dev/null +++ b/public/groupList.ftl @@ -0,0 +1,11 @@ +<#-- @ftlvariable name="groups" type="java.util.ArrayList" --> + +<#import "/includes/template.ftl" as template> +<#import "/includes/group.ftl" as groupTemplate> + +<@template.header "Consumer Groups", "group" /> + +<@groupTemplate.table groups /> + +<@template.footer/> + diff --git a/public/includes/functions.ftl b/public/includes/functions.ftl new file mode 100644 index 000000000..51d6732a3 --- /dev/null +++ b/public/includes/functions.ftl @@ -0,0 +1,8 @@ +<#function filesize num> + <#assign order = num?round?c?length /> + <#assign thousands = ((order - 1) / 3)?floor /> + <#if (thousands < 0)><#assign thousands = 0 /> + <#assign siMap = [ {"factor": 1, "unit": " b"}, {"factor": 1000, "unit": " ko"}, {"factor": 1000000, "unit": " mo"}, {"factor": 1000000000, "unit":" go"}, {"factor": 1000000000000, "unit": " to"} ]/> + <#assign siStr = (num / (siMap[thousands].factor))?string("0.#") + siMap[thousands].unit /> + <#return siStr /> + \ No newline at end of file diff --git a/public/includes/group.ftl b/public/includes/group.ftl new file mode 100644 index 000000000..ad4201dc7 --- /dev/null +++ b/public/includes/group.ftl @@ -0,0 +1,63 @@ +<#-- @ftlvariable name="clusterId" type="java.lang.String" --> + +<#import "node.ftl" as nodeTemplate> + +<#macro table groups> + <#-- @ftlvariable name="groups" type="java.util.List" --> +
+ + + + + + + + + + + + <#if groups?size == 0> + + + + + <#list groups as group> + + + + + + + + + +
IdStateCoordinatorTopics
+ +
${group.getId()}<@state group.getState() /><@nodeTemplate.badge group.getCoordinator()/> + <#list group.getTopics() as topic> + + ${topic} + Lag: ${group.getOffsetLag(topic)} + + + + +
+
+ + +<#macro state state> + <#-- @ftlvariable name="state" type="org.apache.kafka.common.ConsumerGroupState" --> + <#if state.toString() == "Stable"> + <#assign class="success"> + <#elseif state.toString() == "Dead"> + <#assign class="danger"> + <#elseif state.toString() == "Empty"> + <#assign class="warning"> + <#else> + <#assign class="info"> + + ${state.toString()} + \ No newline at end of file diff --git a/public/includes/node.ftl b/public/includes/node.ftl new file mode 100644 index 000000000..5f1e0b443 --- /dev/null +++ b/public/includes/node.ftl @@ -0,0 +1,7 @@ +<#macro badge node> + <#-- @ftlvariable name="node" type="org.kafkahq.models.Node" --> + + + ${node.getId()?c} + + \ No newline at end of file diff --git a/public/includes/template.ftl b/public/includes/template.ftl new file mode 100644 index 000000000..7891fa74a --- /dev/null +++ b/public/includes/template.ftl @@ -0,0 +1,78 @@ +<#-- @ftlvariable name="tab" type="java.lang.String" --> +<#-- @ftlvariable name="clusters" type="java.util.List" --> +<#-- @ftlvariable name="clusterId" type="java.lang.String" --> + +<#macro header title tab> + + + + ${title} | KafkaHQ + + + + ${liveReload?no_esc} + ${vendor_styles?no_esc} + ${template_styles?no_esc} + ${vendor_scripts?no_esc} + ${template_scripts?no_esc} + <#nested> + + +
+ + + +
+
+

+ + + ${title} +

+
+ +
+ + +<#macro footer> +
+
+
+ + <#nested> + + + diff --git a/public/static/_init.scss b/public/static/_init.scss new file mode 100644 index 000000000..07ab9afc9 --- /dev/null +++ b/public/static/_init.scss @@ -0,0 +1,6 @@ +@import "./_variables"; +@import "vendor/bootstrap/scss/_functions"; +@import "vendor/bootstrap/scss/_variables"; +@import "vendor/bootstrap/scss/_mixins"; + + diff --git a/public/static/_variables.scss b/public/static/_variables.scss new file mode 100644 index 000000000..f0206e4c4 --- /dev/null +++ b/public/static/_variables.scss @@ -0,0 +1,150 @@ +/* ----------------------------------------------------------------------------------------------------------------- *\ + Bootstrap +\* ----------------------------------------------------------------------------------------------------------------- */ + +// Typography +$font-family-sans-serif: "Open Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; +$font-family-monospace: "Source Code Pro", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default; +$font-family-title: "Open Sans Condensed", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; +$font-size-base: 0.875rem; + +// Color system +$white: #fff !default; +$gray-100: #f8f9fa !default; +$gray-200: #ebebeb !default; +$gray-300: #dee2e6 !default; +$gray-400: #ced4da !default; +$gray-500: #adb5bd !default; +$gray-600: #999 !default; +$gray-700: #444 !default; +$gray-800: #303030 !default; +$gray-900: #222 !default; +$black: #000 !default; + +$blue: #33b5e5 !default; +$indigo: #005f81 !default; +$purple: #9933cc !default; +$pink: #ff4444 !default; +$red: #d44a3a !default; +$orange: #eb7b18 !default; +$yellow: #ecbb13 !default; +$green: #299c46 !default; +$teal: #20c997 !default; +$cyan: #3498DB !default; + +$primary: $blue !default; +$secondary: $gray-700 !default; +$tertiary: $indigo; +$success: $green !default; +$info: $cyan !default; +$warning: $yellow !default; +$danger: $red !default; +$light: $gray-800 !default; +$dark: $gray-500 !default; + +$yiq-contrasted-threshold: 175 !default; + +// Body + +$body-bg: #171819 !default; +$body-color: $white !default; + +// Links +$link-color: $blue !default; + +// Tables +$table-accent-bg: $gray-800 !default; +$table-border-color: $gray-700 !default; +$table-dark-bg: $black !default; +$table-dark-border-color: $gray-700 !default; +$table-dark-color: $white !default; +$table-hover-bg: darken($primary, 25%); + +// Forms +$input-border-color: transparent !default; +$input-group-addon-color: $gray-500 !default; +$input-group-addon-bg: $gray-700 !default; + +$custom-file-color: $gray-500 !default; +$custom-file-border-color: $gray-700 !default; + +// Dropdowns +$dropdown-bg: $gray-900 !default; +$dropdown-border-color: $gray-700 !default; +$dropdown-divider-bg: $gray-700 !default; +$dropdown-link-color: $white !default; +$dropdown-link-hover-color: $white !default; +$dropdown-link-hover-bg: $primary !default; + +// Navs +$nav-link-padding-x: 2rem !default; +$nav-link-disabled-color: $gray-500 !default; +$nav-tabs-border-color: $gray-700 !default; +$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent !default; +$nav-tabs-link-active-color: $white !default; +$nav-tabs-link-active-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent !default; + +// Navbar +$navbar-padding-y: 1rem !default; +$navbar-dark-color: $white !default; +$navbar-dark-hover-color: $success !default; +$navbar-light-color: rgba($white, .5) !default; +$navbar-light-hover-color: $white !default; +$navbar-light-active-color: $white !default; +$navbar-light-disabled-color: rgba($white, .3) !default; +$navbar-light-toggler-border-color: rgba($white, .1) !default; + +// Pagination +$pagination-color: $white !default; +$pagination-bg: $success !default; +$pagination-border-width: 0 !default; +$pagination-border-color: transparent !default; +$pagination-hover-color: $white !default; +$pagination-hover-bg: lighten($success, 10%) !default; +$pagination-hover-border-color: transparent !default; +$pagination-active-bg: $pagination-hover-bg !default; +$pagination-active-border-color: transparent !default; +$pagination-disabled-color: $white !default; +$pagination-disabled-bg: darken($success, 15%) !default; +$pagination-disabled-border-color: transparent !default; + +// Jumbotron +$jumbotron-bg: $gray-800 !default; + +// Cards +$card-cap-bg: $gray-700 !default; +$card-bg: $gray-800 !default; + +// Popovers +$popover-bg: $gray-800 !default; +$popover-header-bg: $gray-700 !default; + +// Modals +$modal-content-bg: $gray-800 !default; +$modal-content-border-color: $gray-700 !default; +$modal-header-border-color: $gray-700 !default; + +// Progress bars +$progress-height: 0.625rem !default; +$progress-font-size: 0.625rem !default; +$progress-bg: $gray-700 !default; + +// List group +$list-group-bg: $gray-800 !default; +$list-group-border-color: $gray-700 !default; +$list-group-hover-bg: $gray-700 !default; + +// Breadcrumbs +$breadcrumb-bg: $gray-700 !default; + +// Close +$close-color: $white !default; +$close-text-shadow: none !default; + +// Code +$pre-color: inherit !default; + +/* ----------------------------------------------------------------------------------------------------------------- *\ + Font Awesome +\* ----------------------------------------------------------------------------------------------------------------- */ +$fa-font-path: "/vendor/font-awesome/fonts" \ No newline at end of file diff --git a/public/static/img/icon.png b/public/static/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d784cde316aeca7e79193559f5a18b7bc22aa6c7 GIT binary patch literal 30966 zcmd3Ngk^QC&U4P0GiPS*duC#uYpRkGF%SU&K>GBF@(TdK0)NE<2=KrkN4{eh z;13)R#izOi;8!4lbu{>YLboRd9sqz1K7x%0G3Y(H1pi3y`B>jm$JN%;$Ks6*;N#;X zXz$|aVP)ZFBk20ZE)^}y006AOQ{_jxzTbA{d;*PDj}`WZ>Ob%J+G>5)uhhW8xzo=@ ztVkH}MWcg9Aot7oZp6{W-UpK5V34s>2A_!YLI0zx}qsxTy&vfEvpCb@Plq z`3lf>!(WqBY+4Qr1FUY)1WLR^=J1LhVWL;S#6q+Te`FQlvVXRs zLs23cx38pD_~(^<-y;Gx;0c8F⪼0>|b-~Yb?AuU{QQ>d#{<~JAS>y5k5T^h2n;J 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 QC + + + + + + + + + + + + + + Icon Mafia + + + + + image/svg+xml + + + + + + H Q + diff --git a/public/static/img/logo.svg b/public/static/img/logo.svg new file mode 100644 index 000000000..a01d8702a --- /dev/null +++ b/public/static/img/logo.svg @@ -0,0 +1,85 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/public/static/template.js b/public/static/template.js new file mode 100644 index 000000000..22069c125 --- /dev/null +++ b/public/static/template.js @@ -0,0 +1,85 @@ +$(document).on('ready turbolinks:load', function () { + /* Sidebar */ + $('#sidebar-collapse').on('click', function () { + $('#sidebar').toggleClass('active'); + }); + + /* Tooltip */ + $('[data-toggle="tooltip"]').tooltip(); + + /* Tabs */ + const hash = window.location.hash; + if (hash) { + $('ul.nav a[href="' + hash + '"]') + .tab('show') + } + + $('.tabs-container').removeClass('invisible'); + + $('.nav-tabs a').click(function (e) { + $(this).tab('show'); + window.location.hash = this.hash; + }); + + + /* Main action */ + $('td.main-row-action').each(function (key, td) { + $(td).parent() + .addClass("main-row-action") + .on('dblclick', function () { + Turbolinks.visit($(td).find('a').attr('href')); + }) + }); + + /* Confirm */ + var toast = swal.mixin({ + toast: true, + position: 'top-end', + showConfirmButton: false, + timer: 3000 + }); + + $('[data-confirm]').on("click", function (event) { + event.stopPropagation(); + event.preventDefault(); + + var message = 'Are you sure ?'; + var href = $(this).attr("href"); + + if ($(this).attr("data-confirm") !== "true") { + message = $(this).attr("data-confirm"); + } + + swal({ + html: message, + type: 'question', + showCancelButton: true + }).then(function (result) { + if (result.value) { + $.ajax({ + type: "GET", + url: href, + dataType: "json" + }) + .always(function (response) + { + response = response.result ? response : response.responseJSON; + + toast({ + type: response.result ? 'success' : 'error', + title: response.message, + timer: response.result === false ? 0 : 300, + onAfterClose: function () { + if (response.result === true) { + Turbolinks.visit(window.location.href); + } + } + }); + }) + ; + } + }); + + return false; + }); +}); \ No newline at end of file diff --git a/public/static/template.scss b/public/static/template.scss new file mode 100644 index 000000000..0031726d5 --- /dev/null +++ b/public/static/template.scss @@ -0,0 +1,223 @@ +@import "./_init"; +/* ----------------------------------------------------------------------------------------------------------------- *\ + Bootstrap +\* ----------------------------------------------------------------------------------------------------------------- */ + +// Tabs +ul.nav-tabs { + .nav-link { + background-color: transparent; + border: 0 solid; + border-bottom-width: 4px; + border-bottom-color: transparent; + color: $body-color; + + &.active, &:hover.active { + font-weight: bold; + color: $primary; + border-bottom-color: $primary; + border-bottom-width: 4px; + background-color: transparent; + } + + &:hover { + transition-duration: 0ms; + border-bottom-color: $gray-400; + border-bottom-width: 4px; + } + } +} + +.tabs-container { + .tab-content { + padding: 20px; + border: 1px solid $nav-tabs-border-color; + background: $gray-900; + border-top: 0; + } +} + + +/* ----------------------------------------------------------------------------------------------------------------- *\ + Layout +\* ----------------------------------------------------------------------------------------------------------------- */ +a, button, input[type="submit"] { + transition-duration: 200ms; +} + +.title { + display: flex; + position: relative; + width: calc(100% + 40px); + left: -20px; + top: -20px; + height: 74px; + border-bottom: 4px solid $yellow; + padding: 17px 20px 0; +} + +h1 { + font-family: $font-family-title; + font-weight: 700; + font-size: 2rem; + color: white; + margin-bottom: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.wrapper { + display: flex; + width: 100%; + align-items: stretch; +} + +#content { + @media (min-width: 768px) { + width: calc(100% - 250px); + } + + @media (max-width: 768px) { + max-width: 100%; + } + + padding: 20px; + min-height: 100vh; + transition: all 0.3s; +} + +/* ----------------------------------------------------------------------------------------------------------------- *\ + Sidebar +\* ----------------------------------------------------------------------------------------------------------------- */ +#sidebar { + min-width: 250px; + max-width: 250px; + background: $black; + color: #fff; + transition: all 0.3s; + font-family: $font-family-title; + font-size: $font-size-lg; + border-right: 1px solid $nav-tabs-border-color; + + &.active { + margin-left: -250px; + } + .sidebar-header { + padding: 5px 10px; + background: $tertiary; + border-bottom: 4px solid $yellow; + h3 { + margin-bottom: 0; + text-align: center; + sup { + color: $yellow; + } + } + } + ul { + p { + color: #fff; + padding: 10px; + } + li { + a { + padding: 10px; + display: block; + color: #FFFFFF; + &:hover { + background: lighten($tertiary, 10%); + text-decoration: none; + } + } + &.active > a { + color: #fff; + background: $tertiary; + } + + ul { + border-bottom: 1px solid lighten($tertiary, 15%); + li a { + background: $black; + + &.active { + background: $yellow; + color: $black; + } + } + } + } + } +} + +a { + &[aria-expanded="true"] { + color: #fff; + background: $tertiary; + } + &[data-toggle="collapse"] { + position: relative; + } +} + +.dropdown-toggle::after { + display: block; + position: absolute; + top: 50%; + right: 20px; + transform: translateY(-50%); +} + +ul ul a { + font-size: 0.9em !important; + padding-left: 30px !important; + background: $tertiary; +} + + +@media (max-width: 768px) { + #sidebar { + margin-left: -250px; + &.active { + margin-left: 0; + } + } + #sidebar-collapse span { + display: none; + } +} + +/* ----------------------------------------------------------------------------------------------------------------- *\ + Turbolinks +\* ----------------------------------------------------------------------------------------------------------------- */ +.turbolinks-progress-bar { + background-color: $red; +} + +/* ----------------------------------------------------------------------------------------------------------------- *\ + Table +\* ----------------------------------------------------------------------------------------------------------------- */ +table { + .main-row-action { + cursor: pointer; + } + th.row-action { + width: 16px; + } + td.row-action a { + color: $body-color; + } + pre { + white-space: pre-wrap; + overflow-wrap: break-word; + overflow: auto; + max-height: 150px; + + code { + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-all; + } + } +} \ No newline at end of file diff --git a/public/static/vendor.scss b/public/static/vendor.scss new file mode 100644 index 000000000..f4cd355d9 --- /dev/null +++ b/public/static/vendor.scss @@ -0,0 +1,7 @@ +@import "./_init"; + +@import "vendor/bootstrap/scss/bootstrap.scss"; +@import "vendor/font-awesome/scss/font-awesome.scss"; +@import "vendor/sweetalert2/src/sweetalert2.scss"; + + diff --git a/public/topic.ftl b/public/topic.ftl new file mode 100644 index 000000000..ee600ae99 --- /dev/null +++ b/public/topic.ftl @@ -0,0 +1,127 @@ +<#-- @ftlvariable name="clusterId" type="java.lang.String" --> +<#-- @ftlvariable name="topic" type="org.kafkahq.models.Topic" --> +<#-- @ftlvariable name="tab" type="java.lang.String" --> +<#-- @ftlvariable name="datas" type="java.util.List>" --> + +<#import "/includes/template.ftl" as template> +<#import "/includes/node.ftl" as nodeTemplate> +<#import "/includes/group.ftl" as groupTemplate> +<#import "/includes/functions.ftl" as functions> + +<@template.header "Topic: " + topic.getName(), "topic" /> + + + + +<@template.footer/> + diff --git a/public/topicList.ftl b/public/topicList.ftl new file mode 100644 index 000000000..826c0e3bb --- /dev/null +++ b/public/topicList.ftl @@ -0,0 +1,82 @@ +<#-- @ftlvariable name="clusterId" type="java.lang.String" --> +<#-- @ftlvariable name="topics" type="java.util.ArrayList" --> + +<#import "/includes/template.ftl" as template> +<#import "/includes/functions.ftl" as functions> + +<@template.header "Topics", "topic" /> + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + <#if topics?size == 0> + + + + + <#list topics as topic> + + + + + + + + + + + + + + + +
TopicsPartitionsReplicationsConsumers Groups
NameOffsetsSizeTotalAvailableUnder replicatedFactorIn SyncConsumer Groups
+ +
${topic.getName()}${topic.getSumFirstOffsets() + " ⤑ " + topic.getSumOffsets()}${functions.filesize(topic.getLogDirSize())}${topic.getPartitions()?size}${topic.getReplicas()?size}${topic.getInSyncReplicas()?size} + <#list topic.getConsumerGroups() as group> + <#assign active = group.isActiveTopic(topic.getName()) > + + ${group.getId()} + + Lag: ${group.getOffsetLag(topic.getName())} + +
+ +
+ + + <#if topic.isInternal() == false> + + +
+
+ +<@template.footer/> \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..2367dbc16 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'kafkahq' + diff --git a/src/main/java/org/kafkahq/App.java b/src/main/java/org/kafkahq/App.java new file mode 100644 index 000000000..ac52fd57d --- /dev/null +++ b/src/main/java/org/kafkahq/App.java @@ -0,0 +1,64 @@ +package org.kafkahq; + +import org.jooby.Jooby; +import org.jooby.RequestLogger; +import org.jooby.assets.Assets; +import org.jooby.ftl.Ftl; +import org.jooby.json.Jackson; +import org.jooby.livereload.LiveReload; +import org.jooby.whoops.Whoops; +import org.kafkahq.controllers.GroupController; +import org.kafkahq.controllers.TopicController; +import org.kafkahq.modules.KafkaModule; +import org.kafkahq.modules.KafkaWrapper; +import org.kafkahq.repositories.AbstractRepository; + +import java.util.Optional; + +public class App extends Jooby { + // module + { + use("*", new RequestLogger() + .latency() + .extended() + ); + use(new Jackson()); + + on("dev", () -> { + use(new Whoops()); + use(new LiveReload()); + }); + + use(new Assets()); + assets("/favicon.ico"); + use(new Ftl("/", ".ftl")); + + use(KafkaModule.class); + + // @RequestScoped hack + use("*", "/{cluster}/**", (req, rsp, chain) -> { + Optional cluster = req.param("cluster").toOptional(); + cluster.ifPresent(clusterId -> + AbstractRepository.setWrapper(new KafkaWrapper(this.require(KafkaModule.class), clusterId)) + ); + + chain.next(req, rsp); + }); + } + + // route + { + use("*", "/", (req, rsp, chain) -> { + rsp.redirect("/" + this.require(KafkaModule.class).getClustersList().get(0) + "/topic"); + }); + use("*", "/{cluster}", (req, rsp, chain) -> { + rsp.redirect("/" + req.param("cluster").value() + "/topic"); + }); + use(TopicController.class); + use(GroupController.class); + } + + public static void main(String[] args) { + run(App::new, args); + } +} \ No newline at end of file diff --git a/src/main/java/org/kafkahq/controllers/AbstractController.java b/src/main/java/org/kafkahq/controllers/AbstractController.java new file mode 100644 index 000000000..62960b401 --- /dev/null +++ b/src/main/java/org/kafkahq/controllers/AbstractController.java @@ -0,0 +1,17 @@ +package org.kafkahq.controllers; + +import com.google.inject.Inject; +import org.jooby.Request; +import org.jooby.View; +import org.kafkahq.modules.KafkaModule; + +abstract public class AbstractController { + @Inject + private KafkaModule kafkaModule; + + protected View template(Request request, View view) { + return view + .put("clusterId", request.param("cluster").value()) + .put("clusters", this.kafkaModule.getClustersList()); + } +} diff --git a/src/main/java/org/kafkahq/controllers/GroupController.java b/src/main/java/org/kafkahq/controllers/GroupController.java new file mode 100644 index 000000000..639ec529f --- /dev/null +++ b/src/main/java/org/kafkahq/controllers/GroupController.java @@ -0,0 +1,53 @@ +package org.kafkahq.controllers; + +import com.google.inject.Inject; +import org.jooby.Request; +import org.jooby.Results; +import org.jooby.View; +import org.jooby.mvc.GET; +import org.jooby.mvc.Path; +import org.kafkahq.models.ConsumerGroup; +import org.kafkahq.repositories.ConsumerGroupRepository; + +import java.util.concurrent.ExecutionException; + +@Path("/{cluster}/group") +public class GroupController extends AbstractController { + @Inject + private ConsumerGroupRepository consumerGroupRepository; + + @GET + public View list(Request request) throws ExecutionException, InterruptedException { + return this.template( + request, + Results + .html("groupList") + .put("groups", this.consumerGroupRepository.list()) + ); + } + + @GET + @Path("{id}") + public View home(Request request) throws ExecutionException, InterruptedException { + return this.group(request, "topics"); + } + + @GET + @Path("{id}/{tab:(topics|members)}") + public View tab(Request request) throws ExecutionException, InterruptedException { + return this.group(request, request.param("tab").value()); + } + + public View group(Request request, String tab) throws ExecutionException, InterruptedException { + ConsumerGroup group = this.consumerGroupRepository.findByName(request.param("id").value()); + + return this.template( + request, + Results + .html("group") + .put("tab", tab) + .put("group", group) + ); + } + +} diff --git a/src/main/java/org/kafkahq/controllers/TopicController.java b/src/main/java/org/kafkahq/controllers/TopicController.java new file mode 100644 index 000000000..e8d559f8c --- /dev/null +++ b/src/main/java/org/kafkahq/controllers/TopicController.java @@ -0,0 +1,102 @@ +package org.kafkahq.controllers; + +import com.google.inject.Inject; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.jooby.Request; +import org.jooby.Result; +import org.jooby.Results; +import org.jooby.View; +import org.jooby.mvc.GET; +import org.jooby.mvc.Path; +import org.kafkahq.models.Topic; +import org.kafkahq.repositories.RecordRepository; +import org.kafkahq.repositories.TopicRepository; +import org.kafkahq.response.ResultStatusResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; + +@Path("/{cluster}/topic") +public class TopicController extends AbstractController { + private static final Logger logger = LoggerFactory.getLogger(TopicController.class); + + @Inject + private TopicRepository topicRepository; + + @Inject + private RecordRepository recordRepository; + + @GET + public View list(Request request) throws ExecutionException, InterruptedException { + return this.template( + request, + Results + .html("topicList") + .put("topics", this.topicRepository.list()) + ); + } + + @GET + @Path("{id}") + public View home(Request request) throws ExecutionException, InterruptedException { + Topic topic = this.topicRepository.findByName(request.param("id").value()); + List> data = this.recordRepository.consume( + request.param("cluster").value(), + Collections.singletonList(topic.getName()), + new RecordRepository.Options() + ); + + return this.template( + request, + Results + .html("topic") + .put("tab", "data") + .put("topic", topic) + .put("datas", data) + ); + } + + @GET + @Path("{id}/{tab:(partitions|groups)}") + public View tab(Request request) throws ExecutionException, InterruptedException { + return this.topic(request, request.param("tab").value()); + } + + public View topic(Request request, String tab) throws ExecutionException, InterruptedException { + Topic topic = this.topicRepository.findByName(request.param("id").value()); + + return this.template( + request, + Results + .html("topic") + .put("tab", tab) + .put("topic", topic) + ); + } + + @GET + @Path("{id}/delete") + public Result delete(Request request) { + String name = request.param("id").value(); + ResultStatusResponse result = new ResultStatusResponse(); + + try { + this.topicRepository.delete(request.param("cluster").value(), name); + + result.result = true; + result.message = "Topic '" + name + "' is deleted"; + + return Results.with(result, 200); + } catch (Exception exception) { + logger.error("Failed to delete topic " + name, exception); + + result.result = false; + result.message = exception.getCause().getMessage(); + + return Results.with(result, 500); + } + } +} diff --git a/src/main/java/org/kafkahq/models/Cluster.java b/src/main/java/org/kafkahq/models/Cluster.java new file mode 100644 index 000000000..92ddc9cb8 --- /dev/null +++ b/src/main/java/org/kafkahq/models/Cluster.java @@ -0,0 +1,40 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.clients.admin.DescribeClusterResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +@ToString +@EqualsAndHashCode +public class Cluster { + public Cluster(DescribeClusterResult result) throws ExecutionException, InterruptedException { + this.id = result.clusterId().get(); + for(org.apache.kafka.common.Node node : result.nodes().get()) { + this.nodes.add(new Node(node)); + } + + this.controller = new Node(result.controller().get()); + } + + private final String id; + + public String getId() { + return id; + } + + private final List nodes = new ArrayList<>(); + + public List getNodes() { + return nodes; + } + + private final Node controller; + + public Node getController() { + return controller; + } +} diff --git a/src/main/java/org/kafkahq/models/Consumer.java b/src/main/java/org/kafkahq/models/Consumer.java new file mode 100644 index 000000000..85d3e00d8 --- /dev/null +++ b/src/main/java/org/kafkahq/models/Consumer.java @@ -0,0 +1,88 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.clients.admin.MemberDescription; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@ToString +@EqualsAndHashCode +public class Consumer { + public Consumer(MemberDescription description) { + this.id = description.consumerId(); + this.clientId = description.clientId(); + this.host = description.host(); + + for (org.apache.kafka.common.TopicPartition assignment : description.assignment().topicPartitions()) { + this.assignments.add(new TopicPartition(assignment)); + } + + this.assignments.sort(Comparator + .comparing(org.kafkahq.models.TopicPartition::getTopic) + .thenComparingInt(org.kafkahq.models.TopicPartition::getPartition) + ); + } + + private final String id; + + public String getId() { + return id; + } + + private final String clientId; + + public String getClientId() { + return clientId; + } + + private final String host; + + public String getHost() { + return host; + } + + private final ArrayList assignments = new ArrayList<>(); + + public ArrayList getAssignments() { + return assignments; + } + + public ArrayList getGroupedAssignments() { + Map> collect = this.assignments + .stream() + .collect(Collectors.groupingBy(TopicPartition::getTopic)) + ; + + ArrayList list = new ArrayList<>(); + + for(Map.Entry> item : collect.entrySet()) { + list.add(new GroupedAssignement(item.getKey(), item.getValue().stream().mapToInt(TopicPartition::getPartition).toArray())); + } + + return list; + } + + public static class GroupedAssignement { + private GroupedAssignement (String topic, int[] partitions) { + this.topic = topic; + this.partitions = partitions; + } + + private final String topic; + + public String getTopic() { + return topic; + } + + private final int[] partitions; + + public int[] getPartitions() { + return partitions; + } + } +} diff --git a/src/main/java/org/kafkahq/models/ConsumerGroup.java b/src/main/java/org/kafkahq/models/ConsumerGroup.java new file mode 100644 index 000000000..ddea470f8 --- /dev/null +++ b/src/main/java/org/kafkahq/models/ConsumerGroup.java @@ -0,0 +1,146 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.clients.admin.ConsumerGroupDescription; +import org.apache.kafka.clients.admin.MemberDescription; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.ConsumerGroupState; + +import java.util.*; +import java.util.stream.Collectors; + +@ToString +@EqualsAndHashCode +public class ConsumerGroup { + public ConsumerGroup( + ConsumerGroupDescription groupDescription, + Map groupOffset, + Map> topicsOffsets + ) { + this.id = groupDescription.groupId(); + this.isSimpleConsumerGroup = groupDescription.isSimpleConsumerGroup(); + this.partitionAssignor = groupDescription.partitionAssignor(); + this.state = groupDescription.state(); + this.coordinator = new Node(groupDescription.coordinator()); + + for (MemberDescription member : groupDescription.members()) { + this.members.add(new Consumer(member)); + } + + for (Map.Entry offset : groupOffset.entrySet()) { + Partition.Offsets topicOffsets = topicsOffsets.get(offset.getKey().topic()) + .stream() + .filter(item -> item.getPartition() == offset.getKey().partition()) + .findFirst() + .orElseThrow(() -> new NoSuchElementException( + "Topic Partition Offsets '" + offset.getKey().topic() + + "' partition " + offset.getKey().partition() + " doesn't exist for group " + this.id + )); + + this.offsets.add(new TopicPartition.ConsumerGroupOffset( + offset.getKey(), + offset.getValue(), + topicOffsets + )); + } + + for (MemberDescription member : groupDescription.members()) { + for (org.apache.kafka.common.TopicPartition assignment : member.assignment().topicPartitions()) { + long count = this.offsets.stream() + .filter(entry -> entry.getTopic().equals(assignment.topic()) && entry.getPartition() == assignment.partition()) + .count(); + + if (count == 0) { + this.offsets.add(new TopicPartition.ConsumerGroupOffset(assignment)); + } + } + } + + this.offsets.sort(Comparator + .comparing(org.kafkahq.models.TopicPartition.ConsumerGroupOffset::getTopic) + .thenComparingInt(org.kafkahq.models.TopicPartition.ConsumerGroupOffset::getPartition) + ); + } + + private final String id; + + public String getId() { + return id; + } + + private final boolean isSimpleConsumerGroup; + + public boolean isSimpleConsumerGroup() { + return isSimpleConsumerGroup; + } + + private final String partitionAssignor; + + public String partitionAssignor() { + return partitionAssignor; + } + + private final ConsumerGroupState state; + + public ConsumerGroupState getState() { + return state; + } + + private final Node coordinator; + + public Node getCoordinator() { + return coordinator; + } + + private final ArrayList members = new ArrayList<>(); + + public ArrayList getMembers() { + return members; + } + + private final ArrayList offsets = new ArrayList<>(); + + public ArrayList getOffsets() { + return offsets; + } + + public List getActiveTopics() { + return this.getMembers() + .stream() + .flatMap(consumer -> consumer.getAssignments().stream().map(org.kafkahq.models.TopicPartition::getTopic)) + .distinct() + .sorted(String::compareToIgnoreCase) + .collect(Collectors.toList()); + } + + public List getTopics() { + List list = this.getOffsets() + .stream() + .map(org.kafkahq.models.TopicPartition::getTopic) + .distinct() + .collect(Collectors.toList()); + + list.addAll(this.getActiveTopics()); + + return list + .stream() + .distinct() + .sorted(String::compareToIgnoreCase) + .collect(Collectors.toList()); + } + + public boolean isActiveTopic(String topic) { + return this.getActiveTopics().contains(topic); + } + + public long getOffsetLag(String topic) { + return this.offsets.stream() + .filter(consumerGroupOffset -> consumerGroupOffset.getTopic().equals(topic)) + .map(org.kafkahq.models.TopicPartition.ConsumerGroupOffset::getOffsetLag) + .reduce(0L, + (a1, a2) -> a1 + a2.orElse(0L), + (a1, a2) -> a1 + a2 + ); + } +} diff --git a/src/main/java/org/kafkahq/models/LogDir.java b/src/main/java/org/kafkahq/models/LogDir.java new file mode 100644 index 000000000..bb572727b --- /dev/null +++ b/src/main/java/org/kafkahq/models/LogDir.java @@ -0,0 +1,76 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.common.requests.DescribeLogDirsResponse; + +@ToString +@EqualsAndHashCode +public class LogDir { + public LogDir(Integer brokerId, String path, org.apache.kafka.common.TopicPartition topicPartition, DescribeLogDirsResponse.ReplicaInfo replicaInfo) { + this.brokerId = brokerId; + this.path = path; + this.topic = topicPartition.topic(); + this.partition = topicPartition.partition(); + this.size = replicaInfo.size; + this.offsetLag = replicaInfo.offsetLag; + this.isFuture = replicaInfo.isFuture; + } + + private final Integer brokerId; + + public Integer getBrokerId() { + return brokerId; + } + + /** + * The absolute log directory path. + */ + private final String path; + + public String getPath() { + return path; + } + + private final String topic; + + public String getTopic() { + return topic; + } + + private final int partition; + + public int getPartition() { + return partition; + } + + /** + * The size of the log segments of the partition in bytes. + */ + private final long size; + + public long getSize() { + return size; + } + + /** + * The lag of the log's LEO w.r.t. partition's HW + * (if it is the current log for the partition) or current replica's LEO + * (if it is the future log for the partition) + */ + private final long offsetLag; + + public long getOffsetLag() { + return offsetLag; + } + + /** + * True if this log is created by AlterReplicaLogDirsRequest + * and will replace the current log of the replica in the future. + */ + private final boolean isFuture; + + public boolean isFuture() { + return isFuture; + } +} diff --git a/src/main/java/org/kafkahq/models/Node.java b/src/main/java/org/kafkahq/models/Node.java new file mode 100644 index 000000000..d6bd7c898 --- /dev/null +++ b/src/main/java/org/kafkahq/models/Node.java @@ -0,0 +1,62 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@ToString +@EqualsAndHashCode +public class Node { + public Node(org.apache.kafka.common.Node node) { + this.id = node.id(); + this.host = node.host(); + this.port = node.port(); + this.rack = node.rack(); + } + + private final int id; + + public int getId() { + return id; + } + + private final String host; + + public String getHost() { + return host; + } + + private final int port; + + public int getPort() { + return port; + } + + private final String rack; + + public String getRack() { + return rack; + } + + @ToString + @EqualsAndHashCode(callSuper=true) + public static class Partition extends Node + { + public Partition(org.apache.kafka.common.Node node, boolean isLeader, boolean isInSyncReplicas) { + super(node); + this.isLeader = isLeader; + this.isInSyncReplicas = isInSyncReplicas; + } + + private final boolean isLeader; + + public boolean isLeader() { + return isLeader; + } + + private final boolean isInSyncReplicas; + + public boolean isInSyncReplicas() { + return isInSyncReplicas; + } + } +} diff --git a/src/main/java/org/kafkahq/models/Partition.java b/src/main/java/org/kafkahq/models/Partition.java new file mode 100644 index 000000000..12119a49d --- /dev/null +++ b/src/main/java/org/kafkahq/models/Partition.java @@ -0,0 +1,94 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.common.TopicPartitionInfo; + +import java.util.*; + +@ToString +@EqualsAndHashCode +public class Partition { + public Partition(TopicPartitionInfo partitionInfo, LogDir logDir, Offsets offsets) { + this.id = partitionInfo.partition(); + this.logDir = logDir; + this.firstOffset = offsets.getFirstOffset(); + this.lastOffset = offsets.getLastOffset(); + this.nodes = new ArrayList<>(); + + for (org.apache.kafka.common.Node replica : partitionInfo.replicas()) { + nodes.add(new Node.Partition( + replica, + partitionInfo.leader().id() == replica.id(), + partitionInfo.isr().stream().anyMatch(node -> node.id() == replica.id()) + )); + } + } + + private final int id; + + public int getId() { + return id; + } + + private final List nodes; + + public List getNodes() { + return nodes; + } + + private final LogDir logDir; + + public LogDir getLogDir() { + return logDir; + } + + private final long firstOffset; + + public long getFirstOffset() { + return firstOffset; + } + + private final long lastOffset; + + public long getLastOffset() { + return lastOffset; + } + + public Node.Partition getLeader() { + return nodes + .stream() + .filter(Node.Partition::isLeader) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("Leader not found")); + } + + @ToString + @EqualsAndHashCode + public static class Offsets + { + public Offsets(int partition, long start, long lastOffset) { + this.partition = partition; + this.firstOffset = start; + this.lastOffset = lastOffset; + } + + private final int partition; + + public int getPartition() { + return partition; + } + + private long firstOffset; + + public long getFirstOffset() { + return firstOffset; + } + + private long lastOffset; + + public long getLastOffset() { + return lastOffset; + } + } +} diff --git a/src/main/java/org/kafkahq/models/Topic.java b/src/main/java/org/kafkahq/models/Topic.java new file mode 100644 index 000000000..6ffa43c83 --- /dev/null +++ b/src/main/java/org/kafkahq/models/Topic.java @@ -0,0 +1,101 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.common.TopicPartitionInfo; + +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +@ToString +@EqualsAndHashCode +public class Topic { + public Topic( + TopicDescription description, + List consumerGroup, + List logDirs, + List offsets + ) { + this.name = description.name(); + this.internal = description.isInternal(); + this.consumerGroups = consumerGroup; + + for (TopicPartitionInfo partition : description.partitions()) { + this.partitions.add(new Partition( + partition, + logDirs.stream() + .filter(logDir -> logDir.getPartition() == partition.partition()) + .findFirst() + .orElseThrow(() -> new NoSuchElementException( + "Partition '" + partition.partition() + "' doesn't exist for topic " + this.name + )), + offsets.stream() + .filter(offset -> offset.getPartition() == partition.partition()) + .findFirst() + .orElseThrow(() -> new NoSuchElementException( + "Partition Offsets '" + partition.partition() + "' doesn't exist for topic " + this.name + )) + )); + } + } + + private String name; + + public String getName() { + return name; + } + + private boolean internal; + + public boolean isInternal() { + return internal; + } + + private final List partitions = new ArrayList<>(); + + public List getPartitions() { + return partitions; + } + + private List consumerGroups; + + public List getConsumerGroups() { + return consumerGroups; + } + + public List getReplicas() { + return this.getPartitions().stream() + .flatMap(partition -> partition.getNodes().stream()) + .distinct() + .collect(Collectors.toList()); + } + + public List getInSyncReplicas() { + return this.getPartitions().stream() + .flatMap(partition -> partition.getNodes().stream()) + .filter(Node.Partition::isInSyncReplicas) + .distinct() + .collect(Collectors.toList()); + } + + public long getLogDirSize() { + return this.getPartitions().stream() + .map(p -> p.getLogDir().getSize()) + .reduce(0L, Long::sum); + } + + public long getSumFirstOffsets() { + return this.getPartitions().stream() + .map(Partition::getFirstOffset) + .reduce(0L, Long::sum); + } + + public long getSumOffsets() { + return this.getPartitions().stream() + .map(Partition::getLastOffset) + .reduce(0L, Long::sum); + } +} diff --git a/src/main/java/org/kafkahq/models/TopicPartition.java b/src/main/java/org/kafkahq/models/TopicPartition.java new file mode 100644 index 000000000..fd33896f1 --- /dev/null +++ b/src/main/java/org/kafkahq/models/TopicPartition.java @@ -0,0 +1,87 @@ +package org.kafkahq.models; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; + +import java.util.Optional; + +@ToString +@EqualsAndHashCode +public class TopicPartition { + public TopicPartition(org.apache.kafka.common.TopicPartition topicPartition) { + this.topic = topicPartition.topic(); + this.partition = topicPartition.partition(); + } + + private final String topic; + + public String getTopic() { + return topic; + } + + private final int partition; + + public int getPartition() { + return partition; + } + + @ToString + @EqualsAndHashCode(callSuper=true) + public static class ConsumerGroupOffset extends TopicPartition { + public ConsumerGroupOffset(org.apache.kafka.common.TopicPartition topicPartition) { + super(topicPartition); + + this.offset = Optional.empty(); + this.metadata = Optional.empty(); + this.firstOffset = Optional.empty(); + this.lastOffset = Optional.empty(); + } + + public ConsumerGroupOffset( + org.apache.kafka.common.TopicPartition topicPartition, + OffsetAndMetadata offsetAndMetadata, + Partition.Offsets partiionOffsets + ) { + super(topicPartition); + + this.offset = Optional.of(offsetAndMetadata.offset()); + this.metadata = Optional.of(offsetAndMetadata.metadata()); + + this.firstOffset = Optional.of(partiionOffsets.getFirstOffset()); + this.lastOffset = Optional.of(partiionOffsets.getLastOffset()); + } + + private final Optional offset; + + public Optional getOffset() { + return offset; + } + + private final Optional metadata; + + public Optional getMetadata() { + return metadata; + } + + private Optional firstOffset; + + public Optional getFirstOffset() { + return firstOffset; + } + + private Optional lastOffset; + + public Optional getLastOffset() { + return lastOffset; + } + + public Optional getOffsetLag() { + if (this.lastOffset.isPresent() && this.offset.isPresent()) { + return Optional.of(this.lastOffset.get() - this.offset.get()); + } else { + return Optional.empty(); + } + } + } +} diff --git a/src/main/java/org/kafkahq/modules/KafkaModule.java b/src/main/java/org/kafkahq/modules/KafkaModule.java new file mode 100644 index 000000000..a5826b38e --- /dev/null +++ b/src/main/java/org/kafkahq/modules/KafkaModule.java @@ -0,0 +1,94 @@ +package org.kafkahq.modules; + +import com.google.inject.Binder; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.typesafe.config.Config; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.jooby.Env; +import org.jooby.Jooby; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +@Singleton +public class KafkaModule implements Jooby.Module { + private static Logger logger = LoggerFactory.getLogger(KafkaModule.class); + + @Inject + private Config config; + + public T debug(Callable task, String format, Object... arguments) throws ExecutionException, InterruptedException { + long startTime = System.currentTimeMillis(); + T call; + + try { + call = task.call(); + logger.debug("[{}] " + format, (System.currentTimeMillis() - startTime) + "ms", arguments); + return call; + } catch (InterruptedException | ExecutionException exception) { + throw exception; + } catch (Exception exception) { + throw new RuntimeException("Error for " + format, exception); + } + } + + public List getClustersList() { + return this.config + .getConfig("kafka.connections") + .entrySet() + .stream() + .map(entry -> entry.getKey().split("\\.")[0]) + .distinct() + .collect(Collectors.toList()); + } + + private Properties getCommonProperties(String clusterId) { + Properties props = new Properties(); + + this.config.getConfig("kafka.connections." + clusterId) + .entrySet() + .forEach(config -> props.put(config.getKey(), config.getValue().unwrapped())); + + return props; + } + + private Map adminClient = new HashMap<>(); + + public AdminClient getAdminClient(String clusterId) { + if (!this.adminClient.containsKey(clusterId)) { + this.adminClient.put(clusterId, AdminClient.create(this.getCommonProperties(clusterId))); + } + + return this.adminClient.get(clusterId); + } + + private Map> consumer = new HashMap<>(); + + public KafkaConsumer getConsumer(String clusterId) { + if (!this.consumer.containsKey(clusterId)) { + this.consumer.put(clusterId, new KafkaConsumer<>( + this.getCommonProperties(clusterId), + new StringDeserializer(), + new StringDeserializer() + )); + } + + return this.consumer.get(clusterId); + } + + @SuppressWarnings("NullableProblems") + @Override + public void configure(Env env, Config conf, Binder binder) { + binder.bind(KafkaModule.class).toInstance(new KafkaModule()); + } +} diff --git a/src/main/java/org/kafkahq/modules/KafkaWrapper.java b/src/main/java/org/kafkahq/modules/KafkaWrapper.java new file mode 100644 index 000000000..0e507e7d5 --- /dev/null +++ b/src/main/java/org/kafkahq/modules/KafkaWrapper.java @@ -0,0 +1,225 @@ +package org.kafkahq.modules; + +import com.google.inject.Inject; +import org.apache.kafka.clients.admin.*; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.requests.DescribeLogDirsResponse; +import org.jooby.scope.RequestScoped; +import org.kafkahq.models.Partition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.*; + +@RequestScoped +public class KafkaWrapper { + private static Logger logger = LoggerFactory.getLogger(KafkaWrapper.class); + + private KafkaModule kafkaModule; + + private String clusterId; + + @Inject + public KafkaWrapper(KafkaModule kafkaModule, String clusterId) { + this.kafkaModule = kafkaModule; + this.clusterId = clusterId; + } + + private DescribeClusterResult cluster; + + public DescribeClusterResult describeCluster() throws ExecutionException, InterruptedException { + if (this.cluster == null) { + this.cluster = this.kafkaModule.debug(() -> { + DescribeClusterResult cluster = kafkaModule.getAdminClient(clusterId).describeCluster(); + + cluster.clusterId().get(); + cluster.nodes().get(); + cluster.controller().get(); + return cluster; + }, "Cluster"); + } + + return this.cluster; + } + + private Collection listTopics; + + public Collection listTopics() throws ExecutionException, InterruptedException { + if (this.listTopics == null) { + this.listTopics = this.kafkaModule.debug( + () -> kafkaModule.getAdminClient(clusterId).listTopics( + new ListTopicsOptions().listInternal(true) + ).listings().get(), + "List topics" + ); + } + + return this.listTopics; + } + + private Map describeTopics = new HashMap<>(); + + public Map describeTopics(List topics) throws ExecutionException, InterruptedException { + List list = new ArrayList<>(topics); + list.removeIf(value -> this.describeTopics.containsKey(value)); + + if (list.size() > 0) { + Map description = this.kafkaModule.debug( + () -> kafkaModule.getAdminClient(clusterId) + .describeTopics(list) + .all() + .get(), + "Describe Topic {}", + topics + ); + + this.describeTopics.putAll(description); + } + + return this.describeTopics + .entrySet() + .stream() + .filter(e -> topics.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private Map> describeTopicsOffsets = new HashMap<>(); + + public Map> describeTopicsOffsets(List topics) throws ExecutionException, InterruptedException { + List list = new ArrayList<>(topics); + list.removeIf(value -> this.describeTopicsOffsets.containsKey(value)); + + if (list.size() > 0) { + Map> finalOffsets = this.kafkaModule.debug( + () -> { + List collect = this.describeTopics(topics).entrySet() + .stream() + .flatMap(topicDescription -> topicDescription + .getValue() + .partitions() + .stream() + .map(topicPartitionInfo -> + new TopicPartition(topicDescription.getValue().name(), topicPartitionInfo.partition()) + ) + ) + .collect(Collectors.toList()); + + Map begins = kafkaModule.getConsumer(clusterId).beginningOffsets(collect); + // @FIXME: ugly hacks, on startup, first query can send a partial result, resending request works ! + if (begins.size() != collect.size()) { + begins = kafkaModule.getConsumer(clusterId).beginningOffsets(collect); + } + Map ends = kafkaModule.getConsumer(clusterId).endOffsets(collect); + + return begins.entrySet().stream() + .collect(groupingBy( + o -> o.getKey().topic(), + mapping(begin -> + new Partition.Offsets( + begin.getKey().partition(), + begin.getValue(), + ends.get(begin.getKey()) + ), + toList() + ) + )); + }, + "Describe Topic {}", + topics + ); + + this.describeTopicsOffsets.putAll(finalOffsets); + } + + return this.describeTopicsOffsets + .entrySet() + .stream() + .filter(e -> topics.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private Collection listConsumerGroups; + + public Collection listConsumerGroups() throws ExecutionException, InterruptedException { + if (this.listConsumerGroups == null) { + this.listConsumerGroups = this.kafkaModule.debug( + () -> kafkaModule.getAdminClient(clusterId).listConsumerGroups().all().get(), + "List ConsumerGroups" + ); + } + + return this.listConsumerGroups; + } + + private Map describeConsumerGroups = new HashMap<>(); + + public Map describeConsumerGroups(List topics) throws ExecutionException, InterruptedException { + List list = new ArrayList<>(topics); + list.removeIf(value -> this.describeConsumerGroups.containsKey(value)); + + if (list.size() > 0) { + Map description = this.kafkaModule.debug( + () -> kafkaModule.getAdminClient(clusterId) + .describeConsumerGroups(list) + .all() + .get(), + "Describe ConsumerGroups {}", + topics + ); + + this.describeConsumerGroups.putAll(description); + } + + return this.describeConsumerGroups + .entrySet() + .stream() + .filter(e -> topics.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + + private Map> consumerGroupOffset = new HashMap<>(); + + public Map consumerGroupsOffsets(String groupId) throws ExecutionException, InterruptedException { + if (!this.consumerGroupOffset.containsKey(groupId)) { + Map description = this.kafkaModule.debug( + () -> kafkaModule.getAdminClient(clusterId) + .listConsumerGroupOffsets(groupId) + .partitionsToOffsetAndMetadata() + .get(), + "ConsumerGroup Offsets {}", + groupId + ); + + this.consumerGroupOffset.put(groupId, description); + } + + return this.consumerGroupOffset.get(groupId); + } + + private Map> logDirs; + + public Map> describeLogDir() throws ExecutionException, InterruptedException { + if (this.logDirs == null) { + this.logDirs = this.kafkaModule.debug(() -> + kafkaModule.getAdminClient(clusterId) + .describeLogDirs(this.describeCluster().nodes().get() + .stream() + .map(Node::id) + .collect(Collectors.toList()) + ) + .all() + .get(), + "List Log dir" + ); + } + + return this.logDirs; + } +} diff --git a/src/main/java/org/kafkahq/repositories/AbstractRepository.java b/src/main/java/org/kafkahq/repositories/AbstractRepository.java new file mode 100644 index 000000000..98fea585c --- /dev/null +++ b/src/main/java/org/kafkahq/repositories/AbstractRepository.java @@ -0,0 +1,11 @@ +package org.kafkahq.repositories; + +import org.kafkahq.modules.KafkaWrapper; + +abstract public class AbstractRepository { + protected static KafkaWrapper kafkaWrapper; + + public static void setWrapper(KafkaWrapper kafkaWrapper) { + AbstractRepository.kafkaWrapper = kafkaWrapper; + } +} diff --git a/src/main/java/org/kafkahq/repositories/ClusterRepository.java b/src/main/java/org/kafkahq/repositories/ClusterRepository.java new file mode 100644 index 000000000..a659c4fcb --- /dev/null +++ b/src/main/java/org/kafkahq/repositories/ClusterRepository.java @@ -0,0 +1,23 @@ +package org.kafkahq.repositories; + +import com.google.inject.Binder; +import com.google.inject.Singleton; +import com.typesafe.config.Config; +import org.jooby.Env; +import org.jooby.Jooby; +import org.kafkahq.models.Cluster; + +import java.util.concurrent.ExecutionException; + +@Singleton +public class ClusterRepository extends AbstractRepository implements Jooby.Module { + public Cluster get() throws ExecutionException, InterruptedException { + return new Cluster(kafkaWrapper.describeCluster()); + } + + @SuppressWarnings("NullableProblems") + @Override + public void configure(Env env, Config conf, Binder binder) { + binder.bind(ClusterRepository.class).toInstance(new ClusterRepository()); + } +} diff --git a/src/main/java/org/kafkahq/repositories/ConsumerGroupRepository.java b/src/main/java/org/kafkahq/repositories/ConsumerGroupRepository.java new file mode 100644 index 000000000..177fc2c96 --- /dev/null +++ b/src/main/java/org/kafkahq/repositories/ConsumerGroupRepository.java @@ -0,0 +1,84 @@ +package org.kafkahq.repositories; + +import com.google.inject.Binder; +import com.google.inject.Singleton; +import com.typesafe.config.Config; +import org.apache.kafka.clients.admin.ConsumerGroupDescription; +import org.apache.kafka.clients.admin.ConsumerGroupListing; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.TopicPartition; +import org.jooby.Env; +import org.jooby.Jooby; +import org.kafkahq.models.ConsumerGroup; +import org.kafkahq.models.Partition; + +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +@Singleton +public class ConsumerGroupRepository extends AbstractRepository implements Jooby.Module { + public List list() throws ExecutionException, InterruptedException { + ArrayList list = new ArrayList<>(); + + for (ConsumerGroupListing item : kafkaWrapper.listConsumerGroups()) { + list.add(item.groupId()); + } + + List groups = this.findByName(list); + groups.sort(Comparator.comparing(ConsumerGroup::getId)); + + return groups; + } + + public ConsumerGroup findByName(String name) throws ExecutionException, InterruptedException { + Optional topics = this.findByName(new ArrayList() {{ + add(name); + }}).stream().findFirst(); + + return topics.orElseThrow(() -> new NoSuchElementException("Topic '" + name + "' doesn't exist")); + } + + public List findByName(List groups) throws ExecutionException, InterruptedException { + ArrayList list = new ArrayList<>(); + + Set> consumerDescriptions = kafkaWrapper.describeConsumerGroups(groups).entrySet(); + + for (Map.Entry description : consumerDescriptions) { + Map groupsOffsets = kafkaWrapper.consumerGroupsOffsets(description.getKey()); + Map> topicsOffsets = kafkaWrapper.describeTopicsOffsets(groupsOffsets.entrySet() + .stream() + .map(item -> item.getKey().topic()) + .distinct() + .collect(Collectors.toList()) + ); + + list.add(new ConsumerGroup( + description.getValue(), + groupsOffsets, + topicsOffsets + )); + } + + return list; + } + + public List findByTopic(String topic) throws ExecutionException, InterruptedException { + return this.list().stream() + .filter(consumerGroups -> + consumerGroups.getActiveTopics() + .stream() + .anyMatch(s -> Objects.equals(s, topic)) || + consumerGroups.getTopics() + .stream() + .anyMatch(s -> Objects.equals(s, topic)) + ) + .collect(Collectors.toList()); + } + + @SuppressWarnings("NullableProblems") + @Override + public void configure(Env env, Config conf, Binder binder) { + binder.bind(ConsumerGroupRepository.class).toInstance(new ConsumerGroupRepository()); + } +} diff --git a/src/main/java/org/kafkahq/repositories/LogDirRepository.java b/src/main/java/org/kafkahq/repositories/LogDirRepository.java new file mode 100644 index 000000000..f18d62849 --- /dev/null +++ b/src/main/java/org/kafkahq/repositories/LogDirRepository.java @@ -0,0 +1,45 @@ +package org.kafkahq.repositories; + +import com.google.inject.Binder; +import com.google.inject.Singleton; +import com.typesafe.config.Config; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.requests.DescribeLogDirsResponse; +import org.jooby.Env; +import org.jooby.Jooby; +import org.kafkahq.models.LogDir; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +@Singleton +public class LogDirRepository extends AbstractRepository implements Jooby.Module { + public ArrayList list() throws ExecutionException, InterruptedException { + ArrayList list = new ArrayList<>(); + + for(Map.Entry> nodes : kafkaWrapper.describeLogDir().entrySet()) { + for(Map.Entry node: nodes.getValue().entrySet()) { + for(Map.Entry log: node.getValue().replicaInfos.entrySet()) { + list.add(new LogDir(nodes.getKey(), node.getKey(), log.getKey(), log.getValue())); + } + } + } + + return list; + } + + public List findByTopic(String topic) throws ExecutionException, InterruptedException { + return this.list().stream() + .filter(item -> item.getTopic().equals(topic)) + .collect(Collectors.toList()); + } + + @SuppressWarnings("NullableProblems") + @Override + public void configure(Env env, Config conf, Binder binder) { + binder.bind(LogDirRepository.class).toInstance(new LogDirRepository()); + } +} diff --git a/src/main/java/org/kafkahq/repositories/RecordRepository.java b/src/main/java/org/kafkahq/repositories/RecordRepository.java new file mode 100644 index 000000000..7f1043d72 --- /dev/null +++ b/src/main/java/org/kafkahq/repositories/RecordRepository.java @@ -0,0 +1,166 @@ +package org.kafkahq.repositories; + +import com.google.inject.Binder; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.typesafe.config.Config; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +import org.jooby.Env; +import org.jooby.Jooby; +import org.kafkahq.models.Topic; +import org.kafkahq.modules.KafkaModule; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Singleton +public class RecordRepository extends AbstractRepository implements Jooby.Module { + @Inject + private KafkaModule kafkaModule; + + @Inject + private TopicRepository topicRepository; + + public List> consume(String clusterId, List topics, Options options) throws ExecutionException, InterruptedException { + return this.kafkaModule.debug(() -> { + KafkaConsumer consumer = this.kafkaModule.getConsumer(clusterId); + options.seek(topicRepository, consumer, topics); + + List> list = new ArrayList<>(); + HashMap currentOffsets = new HashMap<>(); + boolean isDifferent = true; + + + while (isDifferent) { + ConsumerRecords records = consumer.poll(Duration.ofMillis(200)); + HashMap previousOffsets = new HashMap<>(currentOffsets); + for (ConsumerRecord record : records) { + list.add(record); + currentOffsets.put(record.partition(), record.offset()); + } + + isDifferent = !previousOffsets.equals(currentOffsets); + } + + return list; + }, "Consume {} with options {}", topics, options); + } + + @ToString + @EqualsAndHashCode + public static class Options { + public enum Sort { + OLDEST, + NEWEST, + } + + private int size = 20; + + + public int getSize() { + return size; + } + + public Options setSize(int size) { + this.size = size; + + return this; + } + + private int start = 0; + + public int getStart() { + return start; + } + + public Options setStart(int start) { + this.start = start; + + return this; + } + + private Sort sort = Sort.OLDEST; + + public Sort getSort() { + return sort; + } + + public Options setSort(Sort sort) { + this.sort = sort; + + return this; + } + + private Pattern filter; + + public Pattern getFilter() { + return filter; + } + + public void setFilter(Pattern filter) { + this.filter = filter; + } + + public Integer partition; + + public Integer getPartition() { + return partition; + } + + public Options setPartition(int partition) { + this.partition = partition; + + return this; + } + + private void seek(TopicRepository topicRepository, KafkaConsumer consumer, List topics) throws ExecutionException, InterruptedException { + List topicsDetails = topicRepository.findByName(topics); + + // list partitons + List input = topicsDetails + .stream() + .flatMap(topic -> topic.getPartitions().stream().map(partition -> + new TopicPartition(topic.getName(), partition.getId()) + )) + .collect(Collectors.toList()); + + // filter partitions + if (this.partition != null) { + input = input.stream() + .filter(topicPartition -> topicPartition.partition() == this.partition) + .collect(Collectors.toList()); + } + + consumer.assign(input); + + // offset + if (this.start == 0 && this.sort == Options.Sort.OLDEST) { + consumer.seekToBeginning(input); + } else { + this.findOffset(topicsDetails) + .forEach(consumer::seek); + } + } + + private Map findOffset(List topicsDetails) { + return new HashMap<>(); + } + } + + @SuppressWarnings("NullableProblems") + @Override + public void configure(Env env, Config conf, Binder binder) { + binder.bind(RecordRepository.class).toInstance(new RecordRepository()); + } +} diff --git a/src/main/java/org/kafkahq/repositories/TopicRepository.java b/src/main/java/org/kafkahq/repositories/TopicRepository.java new file mode 100644 index 000000000..dfcff1d27 --- /dev/null +++ b/src/main/java/org/kafkahq/repositories/TopicRepository.java @@ -0,0 +1,84 @@ +package org.kafkahq.repositories; + +import com.google.inject.Binder; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.typesafe.config.Config; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.clients.admin.TopicListing; +import org.jooby.Env; +import org.jooby.Jooby; +import org.kafkahq.models.Partition; +import org.kafkahq.models.Topic; +import org.kafkahq.modules.KafkaModule; + +import java.util.*; +import java.util.concurrent.ExecutionException; + +@Singleton +public class TopicRepository extends AbstractRepository implements Jooby.Module { + @Inject + private KafkaModule kafkaModule; + + @Inject + private ConsumerGroupRepository consumerGroupRepository; + + @Inject + private LogDirRepository logDirRepository; + + public List list() throws ExecutionException, InterruptedException { + ArrayList list = new ArrayList<>(); + + Collection listTopics = kafkaWrapper.listTopics(); + + for (TopicListing item : listTopics) { + list.add(item.name()); + } + + List topics = this.findByName(list); + topics.sort(Comparator.comparing(Topic::getName)); + + return topics; + } + + public Topic findByName(String name) throws ExecutionException, InterruptedException { + Optional topics = this.findByName(new ArrayList() {{ + add(name); + }}).stream().findFirst(); + + return topics.orElseThrow(() -> new NoSuchElementException("Topic '" + name + "' doesn't exist")); + } + + public List findByName(List topics) throws ExecutionException, InterruptedException { + ArrayList list = new ArrayList<>(); + + Set> topicDescriptions = kafkaWrapper.describeTopics(topics).entrySet(); + Map> topicOffsets = kafkaWrapper.describeTopicsOffsets(topics); + + for (Map.Entry description : topicDescriptions) { + list.add( + new Topic( + description.getValue(), + consumerGroupRepository.findByTopic(description.getValue().name()), + logDirRepository.findByTopic(description.getValue().name()), + topicOffsets.get(description.getValue().name()) + ) + ); + } + + return list; + } + + public void delete(String clusterId, String name) throws ExecutionException, InterruptedException { + kafkaModule.getAdminClient(clusterId).deleteTopics(new ArrayList() {{ + add(name); + }}).all().get(); + } + + + @SuppressWarnings("NullableProblems") + @Override + public void configure(Env env, Config conf, Binder binder) { + binder.bind(TopicRepository.class).toInstance(new TopicRepository()); + } +} diff --git a/src/main/java/org/kafkahq/response/ResultStatusResponse.java b/src/main/java/org/kafkahq/response/ResultStatusResponse.java new file mode 100644 index 000000000..3ba0bd94d --- /dev/null +++ b/src/main/java/org/kafkahq/response/ResultStatusResponse.java @@ -0,0 +1,6 @@ +package org.kafkahq.response; + +public class ResultStatusResponse { + public Boolean result; + public String message; +} diff --git a/src/main/java/org/kafkahq/utils/Debug.java b/src/main/java/org/kafkahq/utils/Debug.java new file mode 100644 index 000000000..1fc2cc974 --- /dev/null +++ b/src/main/java/org/kafkahq/utils/Debug.java @@ -0,0 +1,58 @@ +package org.kafkahq.utils; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Debug { + private static final Logger logger = LoggerFactory.getLogger(Thread.currentThread().getStackTrace()[2].getClassName()); + private static final String name = Thread.currentThread().getStackTrace()[2].getClassName(); + + private static String caller() { + return Thread.currentThread().getStackTrace()[3].getClassName() + " -> " + + Thread.currentThread().getStackTrace()[3].getMethodName() + " # " + + Thread.currentThread().getStackTrace()[3].getLineNumber(); + } + + public static String toJson(T arg) { + String output; + + if (arg instanceof String) { + output = (String) arg; + } else if (arg instanceof byte[]) { + output = new String((byte[]) arg); + } else { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + output = gson.toJson(arg); + } + + return output; + } + + public static void time(String message, Runnable runnable, Object... arguments) { + long start = System.currentTimeMillis(); + + runnable.run(); + + logger.trace("[" + (System.currentTimeMillis() - start ) + " ms] " + message, arguments); + } + + @SafeVarargs + public static void print(T... args) { + System.out.println("\033[44;30m " + caller() + " \033[0m"); + + for (Object arg : args) { + System.out.println("\033[46;30m " + arg.getClass().getName() + " \033[0m \n" + toJson(arg)); + } + } + + @SafeVarargs + public static void log(T... args) { + logger.trace("\033[44;30m " + caller() + " \033[0m"); + + for (Object arg : args) { + logger.trace("\033[46;30m " + arg.getClass().getName() + " \033[0m " + toJson(arg)); + } + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..964be89ad --- /dev/null +++ b/yarn.lock @@ -0,0 +1,27 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +bootstrap@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" + +font-awesome@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" + +jquery@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" + +popper.js@^1.14.4: + version "1.14.4" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.4.tgz#8eec1d8ff02a5a3a152dd43414a15c7b79fd69b6" + +sweetalert2@^7.28.0: + version "7.28.0" + resolved "https://registry.yarnpkg.com/sweetalert2/-/sweetalert2-7.28.0.tgz#1833695b7ac2a80e0468ddf2b610c4eeeb4d8ee3" + +turbolinks@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/turbolinks/-/turbolinks-5.2.0.tgz#e6877a55ea5c1cb3bb225f0a4ae303d6d32ff77c"