Restructure, beginning of CLI client
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.jaytux.phoebench.common.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object AuthHandlers {
|
||||
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
|
||||
val useServer = server.maybePrompt("server") { it }
|
||||
Client.onSelectServer(useServer)
|
||||
return runBlocking {
|
||||
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
|
||||
if (handshake.version != ProtocolVersion.VERSION) {
|
||||
Client.onClearServer()
|
||||
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
|
||||
} else Unit.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loginPrompt(server: String?, user: String?, pass: String?): Either<ErrorResponse, Unit> {
|
||||
return runBlocking {
|
||||
serverPrompt(server).bind {
|
||||
val useUser = user.maybePrompt("username") { it }
|
||||
val usePassword = pass.maybePrompt("password", isPassword = true) { it }
|
||||
Client.callRoute(Routes.Auth.login, LoginRequest(useUser, usePassword))
|
||||
}.bind {
|
||||
Client.onLogin(it)
|
||||
Unit.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmtHome(it: HomeResponse) {
|
||||
println("Logged in as ${it.username}${if (it.isAdmin) " (admin)" else ""}.")
|
||||
println("Your projects (${it.ownProjects.size}/${if (it.projectLimit == -1) "∞" else it.projectLimit}):")
|
||||
it.ownProjects.forEach { p ->
|
||||
println(" [${p.id}] ${p.name} (${if (p.isPublic) "public" else "private"} project)")
|
||||
}
|
||||
|
||||
if (it.publicProjects.isNotEmpty()) {
|
||||
println("\nPublicly accessible projects:")
|
||||
it.publicProjects.forEach { p ->
|
||||
println(" [${p.id}] ${p.name} by ${p.owner.name}")
|
||||
}
|
||||
} else {
|
||||
println("\nNo publicly accessible projects.")
|
||||
}
|
||||
}
|
||||
|
||||
fun tryLogin(server: String?, user: String?, pass: String?) {
|
||||
runBlocking {
|
||||
loginPrompt(server, user, pass).bind {
|
||||
Client.callRoute(Routes.home, EmptyRequest())
|
||||
}.fold({
|
||||
System.err.println("Failed to log in: ${it.msg}")
|
||||
exitProcess(-1)
|
||||
}) {
|
||||
fmtHome(it)
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun tryRegister(server: String?, invite: Uuid?, user: String?, pass: String?) {
|
||||
runBlocking {
|
||||
serverPrompt(server).bind {
|
||||
val useInvite = invite.maybePrompt("invite code") {
|
||||
Uuid.parseOrNull(it) ?: run {
|
||||
System.err.println("Invalid invite code format (should be UUID format).")
|
||||
exitProcess(-1)
|
||||
}
|
||||
}
|
||||
val useUser = user.maybePrompt("username") { it }
|
||||
val usePassword = pass.maybePrompt("password", isPassword = true) { it }
|
||||
|
||||
Client.callRoute(Routes.Auth.signup, SignupRequest(useInvite, useUser, usePassword))
|
||||
}.bind {
|
||||
System.err.println("Account created. Loading home...")
|
||||
Client.onLogin(it)
|
||||
Client.callRoute(Routes.home, EmptyRequest())
|
||||
}.fold({
|
||||
System.err.println("Registration pipeline failed: ${it.msg}")
|
||||
}) {
|
||||
fmtHome(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.github.ajalt.clikt.core.*
|
||||
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
|
||||
import com.github.ajalt.clikt.parameters.groups.single
|
||||
import com.github.ajalt.clikt.parameters.options.*
|
||||
import com.github.ajalt.clikt.parameters.types.float
|
||||
import com.github.ajalt.clikt.parameters.types.inputStream
|
||||
import com.jaytux.phoebench.common.TimeUnit
|
||||
import io.ktor.util.reflect.*
|
||||
import io.ktor.utils.io.*
|
||||
import java.io.InputStream
|
||||
import kotlin.reflect.full.isSubclassOf
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object CLI {
|
||||
object Root : CliktCommand("phoebench-cli") {
|
||||
override val invokeWithoutSubcommand: Boolean = true
|
||||
|
||||
val batchMode by option("--batch-mode", "--script-mode",
|
||||
help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
|
||||
).flag(default = false)
|
||||
|
||||
override fun run() {
|
||||
val sub = currentContext.invokedSubcommand
|
||||
if(sub == null) {
|
||||
System.err.println(getFormattedHelp())
|
||||
System.err.println("\nNo subcommand given.")
|
||||
exitProcess(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ICommandContainer {
|
||||
fun nestedCommands(): Array<CliktCommand>
|
||||
}
|
||||
|
||||
annotation class IgnoreNestedCommand
|
||||
|
||||
internal fun ICommandContainer.fromReflection(): Array<CliktCommand> {
|
||||
val nested = this::class.nestedClasses.filter {
|
||||
it.isSubclassOf(CliktCommand::class) && it.annotations.none { a -> a.instanceOf(IgnoreNestedCommand::class) }
|
||||
}
|
||||
|
||||
return nested.mapNotNull {
|
||||
val ctor = it.primaryConstructor
|
||||
if(ctor == null) {
|
||||
System.err.println("Nested command ${it.simpleName} (for ${this::class.simpleName}) has no primary constructor.")
|
||||
null
|
||||
}
|
||||
else if(ctor.parameters.isNotEmpty()) {
|
||||
System.err.println("Primary constructor of nested command ${it.simpleName} (for ${this::class.simpleName}) requires arguments.")
|
||||
null
|
||||
}
|
||||
else {
|
||||
ctor.call() as CliktCommand
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
internal fun ICommandContainer.buildSubcommands(): Array<CliktCommand> {
|
||||
val nested = nestedCommands()
|
||||
nested.forEach {
|
||||
if(it is ICommandContainer) {
|
||||
it.subcommands(*it.buildSubcommands())
|
||||
}
|
||||
}
|
||||
return nested
|
||||
}
|
||||
|
||||
object Commands : ICommandContainer {
|
||||
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
|
||||
|
||||
@Suppress("unused")
|
||||
class Login : CliktCommand(name = "login") {
|
||||
val server by option("--server", help = "The server to connect to (*)")
|
||||
val user by option("--user", help = "The username to log in with (*)")
|
||||
val pass by option("--pass", help = "The password to log in with (*)")
|
||||
|
||||
override fun run() = AuthHandlers.tryLogin(server, user, pass)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
class Register : CliktCommand(name = "register"){
|
||||
val server by option("--server", help = "The server to connect to (*)")
|
||||
val invite by option("--invite", help = "The invite code to register with (*)").convert { Uuid.parse(it) }
|
||||
val user by option("--user", help = "The username for the new account (*)")
|
||||
val pass by option("--pass", help = "The password for the new account (*)")
|
||||
|
||||
override fun run() = AuthHandlers.tryRegister(server, invite, user, pass)
|
||||
}
|
||||
|
||||
class Project : CliktCommand(name = "project"), ICommandContainer {
|
||||
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
|
||||
|
||||
sealed interface IProjectIdentification
|
||||
sealed interface ILabelIdentification
|
||||
data class ProjectName(val user: String, val project: String) : IProjectIdentification
|
||||
data class LabelName(val name: String): ILabelIdentification
|
||||
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
|
||||
|
||||
sealed interface IData<T>
|
||||
data class DirectData<T>(val data: List<T>) : IData<T>
|
||||
data class FileData<T>(val file: InputStream, val parse: (String) -> T) : IData<T> {
|
||||
companion object {
|
||||
fun floatFile(file: InputStream) = FileData<Float>(file) { it.toFloat() }
|
||||
}
|
||||
}
|
||||
|
||||
val finder by mutuallyExclusiveOptions<IProjectIdentification>(
|
||||
option("--id", help = "Find a project by UUID.").convert { ID(Uuid.parse(it)) },
|
||||
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
|
||||
val split = it.split('/')
|
||||
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
|
||||
ProjectName(split[0], split[1])
|
||||
}
|
||||
).single()
|
||||
|
||||
val project by findOrSetObject { this }
|
||||
|
||||
override fun run() {}
|
||||
|
||||
@Suppress("unused")
|
||||
class ProjectList : CliktCommand(name = "list") {
|
||||
override fun run() = ProjectHandlers.list()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
class Details : CliktCommand(name = "details") {
|
||||
val parent by requireObject<Project>()
|
||||
|
||||
override fun run() = ProjectHandlers.details(parent.finder)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
class Create : CliktCommand(name = "create") {
|
||||
val name by option("--name", help = "Set the project's name (*)")
|
||||
val isPublic by option("--public", help = "Make the project publicly visible").flag(default = false)
|
||||
|
||||
override fun run() = ProjectHandlers.create(name, isPublic)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
class AddLabel : CliktCommand(name = "add-label") {
|
||||
val parent by requireObject<Project>()
|
||||
val name by option("--name", help = "Set the label's name (*)")
|
||||
val color by option("--color", help = "Set the label's color (*)").check("Color must be specified in RGB-hex-format (#ABCDEF)") {
|
||||
it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
|
||||
}
|
||||
|
||||
override fun run() = ProjectHandlers.newLabel(name, color, parent.finder)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
class AddData : CliktCommand(name = "add-data") {
|
||||
val parent by requireObject<Project>()
|
||||
val label by mutuallyExclusiveOptions<ILabelIdentification>(
|
||||
option("--label-id", help = "Set the label by UUID.").convert { ID(Uuid.parse(it)) },
|
||||
option("--label", help = "Set the label by name.").convert { LabelName(it) }
|
||||
).single()
|
||||
val warmup by mutuallyExclusiveOptions<IData<Float>>(
|
||||
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
|
||||
option("--warmup-file", help = "Read warmup data from file.").inputStream().convert { FileData.floatFile(it) }
|
||||
).single()
|
||||
val measurement by mutuallyExclusiveOptions<IData<Float>>(
|
||||
option("--measure", help = "Set measurement data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
|
||||
option("--measure-file", help = "Read measurement data from file.").inputStream().convert { FileData.floatFile(it) }
|
||||
).single()
|
||||
val unit by mutuallyExclusiveOptions<TimeUnit>(
|
||||
option("--ns", "--nano", "--nanosec", help = "Set the time unit to nanoseconds").flag().convert { TimeUnit.NANOS },
|
||||
option("--us", "--micro", "--μs", "--microsec", help = "Set the time unit to microseconds").flag().convert { TimeUnit.MICROS },
|
||||
option("--ms", "--milli", "--millis", "--millisec", help = "Set the time unit to milliseconds").flag().convert { TimeUnit.MILLIS },
|
||||
option("--s", "--sec", "--second", help = "Set the time unit to seconds").flag().convert{ TimeUnit.SECONDS },
|
||||
option("--min", "--m", "--minutes", help = "Set the time unit to minutes").flag().convert { TimeUnit.MINUTES },
|
||||
option("--h", "--hour", help = "Set the time unit to hours").flag().convert { TimeUnit.HOURS }
|
||||
).single()
|
||||
|
||||
override fun run() = ProjectHandlers.newData(parent.finder, label, warmup, measurement, unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
Root.subcommands(*Commands.buildSubcommands()).main(args)
|
||||
}
|
||||
catch(e: CancellationException) {}
|
||||
catch(e: PromptException) {
|
||||
System.err.println(e.message)
|
||||
exitProcess(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.jaytux.phoebench.common.ApiRoute
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.IClient
|
||||
import com.jaytux.phoebench.common.RefreshRequest
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.TokenResponse
|
||||
import com.jaytux.phoebench.common.error
|
||||
import com.jaytux.phoebench.common.foldSuspend
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.auth.Auth
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import io.ktor.client.plugins.auth.providers.bearer
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object Client {
|
||||
private var _refAcc = PersistentStorage.refreshToken()
|
||||
private var _serverAcc = PersistentStorage.server()
|
||||
|
||||
private var _server: String? = _serverAcc.load()
|
||||
private var _refreshToken: Uuid? = _server?.let { _refAcc.load() }
|
||||
private var _accessToken: String? = null
|
||||
|
||||
private val _bearer: BearerTokens?
|
||||
get() = _accessToken?.let { acc ->
|
||||
_refreshToken?.let { ref ->
|
||||
BearerTokens(
|
||||
accessToken = acc,
|
||||
refreshToken = ref.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val _authClient = HttpClient(OkHttp) {
|
||||
install(ContentNegotiation) { json() }
|
||||
}
|
||||
|
||||
private val _client = HttpClient(OkHttp) {
|
||||
install(ContentNegotiation) { json() }
|
||||
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
val res = _bearer
|
||||
res
|
||||
}
|
||||
|
||||
refreshTokens {
|
||||
val ref = _refreshToken ?: return@refreshTokens null
|
||||
|
||||
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
|
||||
System.err.println("ERROR while re-authenticating: ${it.msg}")
|
||||
onLogout()
|
||||
null
|
||||
}) {
|
||||
onLogin(it)
|
||||
val res = _bearer
|
||||
res
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
ApiRoute.disablePrinting()
|
||||
}
|
||||
|
||||
private suspend fun <TReq : Any, TRes : Any> callRoute(using: HttpClient, route: ApiRoute<TReq, TRes>, body: TReq, wasInternal: Boolean = false): Either<ErrorResponse, TRes> {
|
||||
return try {
|
||||
val client = IClient.Default(using, _server ?: throw IllegalStateException("No server URL set."))
|
||||
val res = route.call(client, body)
|
||||
res
|
||||
} catch (e: CancellationException) {
|
||||
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
|
||||
} catch (e: Exception) {
|
||||
// println("Call to ${route.pattern} ran into an exception")
|
||||
ErrorResponse(e.message ?: "Unknown error.").error()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
|
||||
callRoute(_client, route, body)
|
||||
|
||||
fun onLogin(tokens: TokenResponse) {
|
||||
_refreshToken = tokens.refresh
|
||||
_accessToken = tokens.access
|
||||
_refAcc.save(tokens.refresh)
|
||||
}
|
||||
|
||||
fun onSelectServer(server: String) {
|
||||
_server = server
|
||||
_serverAcc.save(server)
|
||||
}
|
||||
|
||||
fun onLogout() {
|
||||
_refAcc.erase()
|
||||
_refreshToken = null
|
||||
_accessToken = null
|
||||
}
|
||||
|
||||
fun onClearServer() {
|
||||
_server = null
|
||||
_serverAcc.erase()
|
||||
}
|
||||
|
||||
fun isAuthenticated() = _bearer != null
|
||||
|
||||
fun getServer() = _server
|
||||
|
||||
fun forceGloballyInitialized() {}
|
||||
|
||||
fun shutdown() {
|
||||
_authClient.close()
|
||||
_client.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
Client.forceGloballyInitialized()
|
||||
CLI.main(args)
|
||||
Client.shutdown()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.github.javakeyring.Keyring
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object PersistentStorage {
|
||||
private val json = Json
|
||||
const val SERVICE = "com.jaytux.phoebench.cli"
|
||||
|
||||
class StoredProperty<T>(
|
||||
private val _key: String,
|
||||
private val _toString: (T) -> String, private val _fromString: (String) -> T
|
||||
) {
|
||||
constructor(key: String, serializer: KSerializer<T>) : this(key,
|
||||
{ json.encodeToString(serializer, it) },
|
||||
{ json.decodeFromString(serializer, it) }
|
||||
)
|
||||
|
||||
fun load(): T? = runCatching {
|
||||
Keyring.create().use {
|
||||
it.getPassword(SERVICE, _key)
|
||||
}
|
||||
}.getOrNull()?.let { _fromString(it) }
|
||||
|
||||
fun save(value: T) = runCatching {
|
||||
val keyring = Keyring.create().use {
|
||||
it.setPassword(SERVICE, _key, _toString(value))
|
||||
}
|
||||
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
|
||||
|
||||
fun erase() = runCatching {
|
||||
val keyring = Keyring.create().use {
|
||||
it.deletePassword(SERVICE, _key)
|
||||
}
|
||||
}.ignore()
|
||||
}
|
||||
|
||||
fun refreshToken() = StoredProperty("refresh_token", serializer<Uuid>())
|
||||
|
||||
fun server() = StoredProperty("server_url", {it}, {it})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.jaytux.phoebench.common.EmptyRequest
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.TimeUnit
|
||||
import com.jaytux.phoebench.common.fold
|
||||
|
||||
object ProjectHandlers {
|
||||
fun list() {
|
||||
tryAuthenticated {
|
||||
Client.callRoute(Routes.home, EmptyRequest())
|
||||
}.fold({ err ->
|
||||
System.err.println("Could not get projects list: ${err.msg}")
|
||||
}) {
|
||||
System.err.println("Logged in as ${it.username}")
|
||||
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
|
||||
println("[${project.id}] ${project.owner.name}/${project.name} (${if(project.isPublic) "public" else "private"})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun details(find: CLI.Commands.Project.IProjectIdentification?) {
|
||||
//
|
||||
}
|
||||
|
||||
fun create(name: String?, isPublic: Boolean) {}
|
||||
|
||||
fun newLabel(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?) {}
|
||||
|
||||
fun newData(
|
||||
project: CLI.Commands.Project.IProjectIdentification?,
|
||||
label: CLI.Commands.Project.ILabelIdentification?,
|
||||
warmup: CLI.Commands.Project.IData<Float>?,
|
||||
measurement: CLI.Commands.Project.IData<Float>?,
|
||||
unit: TimeUnit?
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.jaytux.phoebench.clients.cli.CLI.Root
|
||||
import com.jaytux.phoebench.common.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Locale.getDefault
|
||||
|
||||
fun <T> T.ignore(): Unit {}
|
||||
|
||||
class PromptException(val what: String) : Exception("Missing $what (perhaps you forgot to disable batch-mode?)")
|
||||
|
||||
fun <T> T?.maybePrompt(what: String, isPassword: Boolean = false, converter: (String) -> T): T {
|
||||
if(this != null) return this
|
||||
if(Root.batchMode) throw PromptException(what)
|
||||
|
||||
print("${what.replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() }}: ")
|
||||
val got = if(isPassword) {
|
||||
String(System.console().readPassword())
|
||||
}
|
||||
else readln()
|
||||
|
||||
return converter(got)
|
||||
}
|
||||
|
||||
inline fun <reified V> tryAuthenticated(crossinline body: suspend () -> Either<ErrorResponse, V>) = runBlocking {
|
||||
body().fold({
|
||||
if(!Client.isAuthenticated()) {
|
||||
System.err.println("Authentication failed. Please log in${Client.getServer()?.let { s -> " to $s" } ?: ""} again.")
|
||||
if(!Root.batchMode) {
|
||||
AuthHandlers.loginPrompt(Client.getServer(), null, null).fold({ err ->
|
||||
err.error()
|
||||
}) {
|
||||
body()
|
||||
}
|
||||
}
|
||||
else it.error()
|
||||
} else it.error()
|
||||
}) {
|
||||
it.value()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.slf4j.simpleLogger.defaultLogLevel = off
|
||||
Reference in New Issue
Block a user