diff --git a/.gitignore b/.gitignore index b95e3780d..220269953 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,12 @@ Thumbs.db # Project specific /mnt/*.json /mnt/*.backup +/mnt/widget* /mnt/content/*.json /mnt/.version /cogboard-compose.yml +/mnt/mongo/* +!/mnt/mongo/.gitkeep # Intellij # ###################### diff --git a/api-mocks/__files/logViewer/logs.json b/api-mocks/__files/logViewer/logs.json new file mode 100644 index 000000000..e5c4e7400 --- /dev/null +++ b/api-mocks/__files/logViewer/logs.json @@ -0,0 +1,11 @@ +{ + "logLines": [ + "02:49:12 127.0.0.1 GET / 200", + "02:49:35 127.0.0.1 GET /index.html 200", + "03:01:06 127.0.0.1 GET /images/sponsered.gif 304", + "03:52:36 127.0.0.1 GET /search.php 200", + "04:17:03 127.0.0.1 GET /admin/style.css 200", + "05:04:54 127.0.0.1 GET /favicon.ico 404", + "05:38:07 127.0.0.1 GET /js/ads.js 200" + ] +} \ No newline at end of file diff --git a/api-mocks/mappings/endpoints-mapping.json b/api-mocks/mappings/endpoints-mapping.json index 5a3ade401..d98b991d2 100644 --- a/api-mocks/mappings/endpoints-mapping.json +++ b/api-mocks/mappings/endpoints-mapping.json @@ -1,5 +1,23 @@ { "mappings": [ + { + "request": { + "method": "GET", + "url": "/log-viewer", + "queryParameters": { + "lines": { + "matches": "^[0-9]+$" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "logViewer/logs.json" + } + }, { "request": { "method": "GET", diff --git a/build.gradle.kts b/build.gradle.kts index 4451a9a0c..61c3cb243 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,7 +55,7 @@ allprojects { tasks { named("build") { - dependsOn(":cogboard-app:test", ":cogboard-webapp:buildImage") + dependsOn(":cogboard-app:test", ":cogboard-webapp:buildImage", ":ssh:buildImage") } register("cypressInit", Exec::class) { setWorkingDir("./functional/cypress-tests") diff --git a/cogboard-app/build.gradle.kts b/cogboard-app/build.gradle.kts index 6cb0de1a2..ea4dc4b39 100644 --- a/cogboard-app/build.gradle.kts +++ b/cogboard-app/build.gradle.kts @@ -1,45 +1,47 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - kotlin("jvm") -} - -tasks.named("test") { - useJUnitPlatform() -} - -dependencies { - - "io.knotx:knotx".let { v -> - implementation(platform("$v-dependencies:${project.property("knotx.version")}")) - implementation("$v-server-http-api:${project.property("knotx.version")}") - } - "io.vertx:vertx".let { v -> - implementation("$v-web") - implementation("$v-auth-jwt") - implementation("$v-web-client") - implementation("$v-rx-java2") - } - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.0") - implementation(kotlin("stdlib-jdk8")) - - testImplementation("org.assertj:assertj-core:3.12.2") - testImplementation("org.junit.jupiter:junit-jupiter-api:5.4.2") - testImplementation("org.junit.jupiter:junit-jupiter-params:5.3.1") - testImplementation("org.mockito:mockito-junit-jupiter:3.1.0") - testImplementation(gradleTestKit()) - testRuntime("org.junit.jupiter:junit-jupiter-engine:5.4.2") -} - -repositories { - mavenCentral() -} - -val compileKotlin: KotlinCompile by tasks -compileKotlin.kotlinOptions { - jvmTarget = "1.8" -} -val compileTestKotlin: KotlinCompile by tasks -compileTestKotlin.kotlinOptions { - jvmTarget = "1.8" +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} + +tasks.named("test") { + useJUnitPlatform() +} + +dependencies { + + "io.knotx:knotx".let { v -> + implementation(platform("$v-dependencies:${project.property("knotx.version")}")) + implementation("$v-server-http-api:${project.property("knotx.version")}") + } + "io.vertx:vertx".let { v -> + implementation("$v-web") + implementation("$v-auth-jwt") + implementation("$v-web-client") + implementation("$v-rx-java2") + } + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.0") + implementation(kotlin("stdlib-jdk8")) + implementation("com.jcraft:jsch:0.1.55") + implementation("org.mongodb:mongo-java-driver:3.12.10") + + testImplementation("org.assertj:assertj-core:3.12.2") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.4.2") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.3.1") + testImplementation("org.mockito:mockito-junit-jupiter:3.1.0") + testImplementation(gradleTestKit()) + testRuntime("org.junit.jupiter:junit-jupiter-engine:5.4.2") +} + +repositories { + mavenCentral() +} + +val compileKotlin: KotlinCompile by tasks +compileKotlin.kotlinOptions { + jvmTarget = "1.8" +} +val compileTestKotlin: KotlinCompile by tasks +compileTestKotlin.kotlinOptions { + jvmTarget = "1.8" } \ No newline at end of file diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/CogboardConstants.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/CogboardConstants.kt index b781386a9..07f3482cc 100644 --- a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/CogboardConstants.kt +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/CogboardConstants.kt @@ -38,7 +38,18 @@ class CogboardConstants { const val SCHEDULE_PERIOD = "schedulePeriod" const val SCHEDULE_PERIOD_DEFAULT = 120L // 120 seconds const val SCHEDULE_DELAY_DEFAULT = 10L // 10 seconds + const val SSH_TIMEOUT = 5000 // 5000ms -> 5s + const val SSH_HOST = "sshAddress" + const val SSH_PORT = "sshPort" + const val SSH_KEY = "sshKey" + const val SSH_KEY_PASSPHRASE = "sshKeyPassphrase" const val URL = "url" + + const val LOG_REQUEST_TYPE = "logRequestType" + const val LOG_LINES = "logLinesField" + const val LOG_FILE_SIZE = "logFileSizeField" + const val LOG_EXPIRATION_DAYS = "logRecordExpirationField" + const val LOG_PARSER = "logParserField" const val REQUEST_ID = "requestId" const val PUBLIC_URL = "publicUrl" const val USER = "user" @@ -78,6 +89,13 @@ class CogboardConstants { } } + class ConnectionType { + companion object { + const val SSH = "SSH" + const val HTTP = "HTTP" + } + } + class RequestMethod { companion object { const val GET = "get" diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/EndpointLoader.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/EndpointLoader.kt index 97609a8c2..bf4712cbe 100644 --- a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/EndpointLoader.kt +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/EndpointLoader.kt @@ -31,6 +31,8 @@ class EndpointLoader( this.put(Props.USER, credentials.getString(Props.USER) ?: "") this.put(Props.PASSWORD, credentials.getString(Props.PASSWORD) ?: "") this.put(Props.TOKEN, credentials.getString(Props.TOKEN) ?: "") + this.put(Props.SSH_KEY, credentials.getString(Props.SSH_KEY) ?: "") + this.put(Props.SSH_KEY_PASSPHRASE, credentials.getString(Props.SSH_KEY_PASSPHRASE) ?: "") } } return this diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/controller/CredentialsController.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/controller/CredentialsController.kt index b417d92b6..e9288c042 100644 --- a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/controller/CredentialsController.kt +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/controller/CredentialsController.kt @@ -37,6 +37,8 @@ class CredentialsController : AbstractVerticle() { private fun JsonObject.filterSensitiveData(): JsonObject { this.remove(Props.PASSWORD) + this.remove(Props.SSH_KEY) + this.remove(Props.SSH_KEY_PASSPHRASE) return this } diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/model/Credential.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/model/Credential.kt index 19cb04715..6727bf401 100644 --- a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/model/Credential.kt +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/config/model/Credential.kt @@ -5,5 +5,7 @@ data class Credential( val label: String, val user: String, val password: String?, - val token: String? + val token: String?, + val sshKey: String?, + val sshKeyPassphrase: String? ) diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/LogController.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/LogController.kt new file mode 100644 index 000000000..ee6c0110d --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/LogController.kt @@ -0,0 +1,63 @@ +package com.cognifide.cogboard.logStorage + +import com.cognifide.cogboard.CogboardConstants.Props +import com.cognifide.cogboard.logStorage.model.Log +import com.cognifide.cogboard.logStorage.model.asLog +import com.cognifide.cogboard.storage.VolumeStorageFactory.boards +import com.cognifide.cogboard.widget.type.logviewer.LogViewerWidget +import com.mongodb.client.model.Sorts +import io.knotx.server.api.handler.RoutingHandlerFactory +import io.vertx.core.Handler +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import io.vertx.core.logging.Logger +import io.vertx.core.logging.LoggerFactory +import io.vertx.reactivex.core.Vertx +import io.vertx.reactivex.ext.web.RoutingContext +import java.time.Instant + +class LogController : RoutingHandlerFactory { + + override fun getName(): String = "logs-handler" + + override fun create(vertx: Vertx?, config: JsonObject?): Handler = Handler { context -> + context?.request()?.params()?.get("id")?.let { id -> + val logs = JsonArray(getLogs(id).map { it.toJson() }) + context.response().end(logs.encode()) + } + } + + private fun getLogs(id: String): List { + val logLines = boards() + .loadConfig() + .getJsonObject(Props.WIDGETS) + .getJsonObject(Props.WIDGETS_BY_ID) + .getJsonObject(id) + .getInteger(Props.LOG_LINES) + ?: LogViewerWidget.DEFAULT_LOG_LINES.toInt() + + return if (LOGGER.isDebugEnabled) { + val start = Instant.now() + val logs = fetchLogs(id, logLines) + val took = Instant.now().minusMillis(start.toEpochMilli()).toEpochMilli() + LOGGER.debug("DB query for $id took $took[ms] for getting $logLines (processed logs: ${logs.size})") + logs + } else { + fetchLogs(id, logLines) + } + } + + private fun fetchLogs(id: String, logLines: Int): List { + return LogStorage.database + .getCollection(id) + .find() + .sort(Sorts.descending(Log.SEQ)) + .limit(logLines) + .map { it.asLog() } + .sortedBy { it.seq } + } + + companion object { + val LOGGER: Logger = LoggerFactory.getLogger(LogController::class.java) + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/LogStorage.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/LogStorage.kt new file mode 100644 index 000000000..f977e28a4 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/LogStorage.kt @@ -0,0 +1,277 @@ +package com.cognifide.cogboard.logStorage + +import com.cognifide.cogboard.logStorage.model.Log +import com.cognifide.cogboard.logStorage.model.LogCollectionState +import com.cognifide.cogboard.logStorage.model.asLogCollectionState +import com.cognifide.cogboard.logStorage.model.LogStorageConfiguration +import com.cognifide.cogboard.logStorage.model.LogVariableData +import com.cognifide.cogboard.logStorage.model.QuarantineRule +import com.cognifide.cogboard.widget.connectionStrategy.ConnectionStrategy +import com.cognifide.cogboard.widget.type.logviewer.logparser.LogParserStrategy +import com.mongodb.client.MongoClient +import com.mongodb.MongoException +import com.mongodb.client.MongoClients +import com.mongodb.client.MongoCollection +import com.mongodb.client.MongoDatabase +import com.mongodb.client.model.Filters.eq +import com.mongodb.client.model.Filters.lt +import com.mongodb.client.model.Filters.`in` +import com.mongodb.client.model.Filters.regex +import com.mongodb.client.model.Filters.or +import com.mongodb.client.model.Indexes +import com.mongodb.client.model.ReplaceOptions +import com.mongodb.client.model.Sorts.ascending +import io.vertx.core.AbstractVerticle +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import io.vertx.core.logging.Logger +import io.vertx.core.logging.LoggerFactory +import org.bson.Document +import java.net.URI +import java.time.Instant + +class LogStorage( + private val config: LogStorageConfiguration, + private val connection: ConnectionStrategy, + private val parserStrategy: LogParserStrategy, + var rules: List = emptyList() +) : AbstractVerticle() { + + /** Returns the list of regexes of enabled rules. */ + private val enabledRegexes: List + get() { + val now = Instant.now() + return rules + .filter { rule -> + rule.enabled && (rule.endTimestamp?.let { it > now } ?: true) + } + .map { it.regex } + } + + override fun start() { + super.start() + logsCollection.createIndex(Indexes.descending(Log.SEQ)) + } + + /** Returns a logs collection associated with this widget. */ + private val logsCollection: MongoCollection + get() = database.getCollection(config.id) + + // Storage configuration + + /** Returns a logs collection configuration associated with this widget (if present). */ + private val collectionState: LogCollectionState? + get() = stateCollection + .find(eq(Log.ID, config.id)) + .first() + ?.asLogCollectionState() + + /** Saves or deletes a logs collection [state] associated with this widget. */ + private fun saveState(state: LogCollectionState?) { + if (state != null) { + val options = ReplaceOptions().upsert(true) + stateCollection + .replaceOne( + eq(LogCollectionState.ID, config.id), + state.toDocument(), + options + ) + } else { + stateCollection.deleteOne((eq(LogCollectionState.ID, config.id))) + } + } + + // MongoDB - logs + + /** Deletes all logs from the collection. */ + private fun deleteAllLogs() { + logsCollection.drop() + } + + /** Deletes the [n] first logs (ordered by their sequence number). */ + private fun deleteFirstLogs(n: Long) { + val ids = logsCollection + .find() + .sort(ascending(Log.SEQ)) + .limit(n.toInt()) + .map { it.getObjectId(Log.ID) } + .toList() + if (ids.isNotEmpty()) { + try { + val result = logsCollection.deleteMany(`in`(Log.ID, ids)) + LOGGER.debug("Deleted ${result.deletedCount} first logs") + } catch (exception: MongoException) { + LOGGER.error("Cannot delete first logs: $exception") + } + } + } + + /** Deletes old logs (based on the number of days before expiration). */ + private fun deleteOldLogs() { + val now = Instant.now().epochSecond + val beforeTimestamp = now - (config.expirationDays * DAY_TO_TIMESTAMP) + try { + val result = logsCollection.deleteMany(lt(Log.INSERTED_ON, beforeTimestamp)) + LOGGER.debug("Deleted ${result.deletedCount} old logs") + } catch (exception: MongoException) { + LOGGER.error("Cannot delete old logs: $exception") + } + } + + /** Deletes oldest logs when logs take up too much space. */ + private fun deleteSpaceConsumingLogs() { + val size = database + .runCommand(Document(STATS_COMMAND, config.id)) + .getInteger(STATS_SIZE) + val maxSize = config.fileSizeMB * MB_TO_BYTES + if (size > 0 && size > maxSize) { + val deleteFactor = ((size - maxSize).toDouble() / size) + val logCount = logsCollection.countDocuments() + val toDelete = (logCount.toDouble() * deleteFactor).toLong() + if (toDelete > 0) { + LOGGER.debug("Deleting $toDelete logs as the size $size exceeds maximum size of $maxSize") + deleteFirstLogs(toDelete) + } + } + } + + /** Filters in place the logs not matching the rules. */ + private fun filter(logs: MutableList) { + val regexes = enabledRegexes + if (regexes.isEmpty()) { return } + logs.removeAll { log -> + log.variableData.any { variable -> + regexes.any { it.containsMatchIn(variable.header) } + } + } + } + + /** Deletes logs not matching to the rules from the database. */ + fun filterExistingLogs() { + val fieldName = Log.VARIABLE_DATA + "." + LogVariableData.HEADER + val regexes = enabledRegexes.map { regex(fieldName, it.pattern) } + + if (regexes.isEmpty()) { return } + + logsCollection.deleteMany(or(regexes)) + } + + /** Downloads new logs and filters them by quarantine rules. */ + private fun downloadFilterLogs(skipFirstLines: Long? = null): List { + val logs = connection + .getLogs(skipFirstLines) + .mapNotNull { parserStrategy.parseLine(it) } + .toMutableList() + + filter(logs) + + return logs + } + + /** Inserts the logs to the database. */ + private fun insertLogs(seq: Long, logs: Collection) { + if (logs.isEmpty()) return + + var sequence = seq + + logs.forEach { + it.seq = sequence + sequence++ + } + + logsCollection.insertMany(logs.map { it.toDocument() }) + } + + /** Checks how many logs to download, downloads them and saves them to the database. */ + private fun downloadLogs(): List { + var lastLine = collectionState?.lastLine ?: 0 + var seq = collectionState?.seq ?: 0 + var newLogs: List = emptyList() + + val fileLineCount = connection.getNumberOfLines() ?: 0 + + if (fileLineCount > 0 && fileLineCount > lastLine) { + newLogs = downloadFilterLogs(lastLine) + } else if (fileLineCount in 1 until lastLine) { + deleteAllLogs() + seq = 0 + lastLine = 0 + newLogs = downloadFilterLogs() + } + + insertLogs(seq, newLogs) + lastLine += newLogs.size + seq += newLogs.size + + saveState(LogCollectionState(config.id, lastLine, seq)) + + return newLogs + } + + /** Prepares a JSON response to be displayed. */ + private fun prepareResponse(insertedLogs: List): JsonObject { + return JsonObject(mapOf( + "variableFields" to parserStrategy.variableFields, + "logs" to JsonArray(insertedLogs.map { it.toJson() }) + )) + } + + /** Updates the logs and sends them to the widget. */ + fun updateLogs(fetchNewLogs: Boolean) { + var insertedLogs: List = emptyList() + + if (fetchNewLogs) { + insertedLogs = downloadLogs() + + deleteOldLogs() + deleteSpaceConsumingLogs() + } + + val response = prepareResponse(insertedLogs) + vertx.eventBus().send(config.eventBusAddress, response) + } + + /** Deletes all data associated with the widget. */ + fun delete() { + deleteAllLogs() + saveState(null) + } + + companion object { + private const val DATABASE_NAME: String = "logs" + private const val STATE_COLLECTION_NAME: String = "config" + private const val STATS_COMMAND: String = "collStats" + private const val STATS_SIZE: String = "size" + private const val MB_TO_BYTES: Long = 1024L * 1024L + private const val DAY_TO_TIMESTAMP = 24 * 60 * 60 + private const val MONGO_SCHEME = "mongodb" + private val MONGO_USERNAME = System.getenv("MONGO_USERNAME") ?: "root" + private val MONGO_PASSWORD = System.getenv("MONGO_PASSWORD") ?: "root" + private val MONGO_HOST = System.getenv("MONGO_HOST") ?: "mongo-logs-storage" + private val MONGO_PORT = System.getenv("MONGO_PORT")?.toIntOrNull() ?: 27017 + + /** Returns a shared instance of the Mongo client. */ + private val mongoClient: MongoClient by lazy { + val uri = URI( + MONGO_SCHEME, + "$MONGO_USERNAME:$MONGO_PASSWORD", + MONGO_HOST, + MONGO_PORT, + null, + null, + null + ) + MongoClients.create(uri.toString()) + } + + /** Returns a database for storing logs and collection states. */ + val database: MongoDatabase + get() = mongoClient.getDatabase(DATABASE_NAME) + + /** Returns a state collection. */ + val stateCollection: MongoCollection + get() = database.getCollection(STATE_COLLECTION_NAME) + + private val LOGGER: Logger = LoggerFactory.getLogger(LogStorage::class.java) + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/Log.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/Log.kt new file mode 100644 index 000000000..60433a7cf --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/Log.kt @@ -0,0 +1,55 @@ +package com.cognifide.cogboard.logStorage.model + +import io.vertx.core.json.JsonObject +import org.bson.Document +import org.bson.types.ObjectId +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +data class Log( + var id: ObjectId = ObjectId(), + var seq: Long = 0, + var insertedOn: Long = Instant.now().epochSecond, + var date: Long, + var type: String, + var variableData: List +) { + fun toDocument() = Document(mapOf( + ID to id, + SEQ to seq, + INSERTED_ON to insertedOn, + DATE to date, + TYPE to type, + VARIABLE_DATA to variableData.map { it.toDocument() } + )) + fun toJson() = JsonObject(mapOf( + ID to id.toHexString(), + SEQ to seq, + INSERTED_ON to insertedOn, + DATE to (LocalDateTime + .ofEpochSecond(date, 0, ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_DATE_TIME) ?: ""), + TYPE to type, + VARIABLE_DATA to variableData.map { it.toJson() } + )) + + companion object { + const val ID = "_id" + const val SEQ = "seq" + const val INSERTED_ON = "insertedOn" + const val DATE = "date" + const val TYPE = "type" + const val VARIABLE_DATA = "variableData" + } +} + +fun Document.asLog() = Log( + getObjectId(Log.ID), + getLong(Log.SEQ), + getLong(Log.INSERTED_ON), + getLong(Log.DATE), + getString(Log.TYPE), + getList(Log.VARIABLE_DATA, Document::class.java).map { it.asLogVariableData() } +) diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogCollectionState.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogCollectionState.kt new file mode 100644 index 000000000..38905824d --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogCollectionState.kt @@ -0,0 +1,29 @@ +package com.cognifide.cogboard.logStorage.model + +import org.bson.Document + +data class LogCollectionState( + var id: String, + var lastLine: Long, + var seq: Long +) { + private val map: Map + get() = mapOf( + ID to id, + LAST_LINE to lastLine, + SEQ to seq + ) + fun toDocument() = Document(map) + + companion object { + const val ID = "_id" + const val LAST_LINE = "lastLine" + const val SEQ = "seq" + } +} + +fun Document.asLogCollectionState() = LogCollectionState( + getString(LogCollectionState.ID), + getLong(LogCollectionState.LAST_LINE), + getLong(LogCollectionState.SEQ) +) diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogStorageConfiguration.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogStorageConfiguration.kt new file mode 100644 index 000000000..7e3b2ec13 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogStorageConfiguration.kt @@ -0,0 +1,9 @@ +package com.cognifide.cogboard.logStorage.model + +data class LogStorageConfiguration( + val id: String, + val logLines: Long, + val fileSizeMB: Long, + val expirationDays: Long, + val eventBusAddress: String +) diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogVariableData.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogVariableData.kt new file mode 100644 index 000000000..33efe6873 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/LogVariableData.kt @@ -0,0 +1,27 @@ +package com.cognifide.cogboard.logStorage.model + +import io.vertx.core.json.JsonObject +import org.bson.Document + +data class LogVariableData( + val header: String, + var description: String +) { + private val map: Map + get() = mapOf( + HEADER to header, + DESCRIPTION to description) + + fun toDocument() = Document(map) + fun toJson() = JsonObject(map) + + companion object { + const val HEADER = "header" + const val DESCRIPTION = "description" + } +} + +fun Document.asLogVariableData() = LogVariableData( + getString(LogVariableData.HEADER), + getString(LogVariableData.DESCRIPTION) +) diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/QuarantineRule.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/QuarantineRule.kt new file mode 100644 index 000000000..dbc3b3524 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/logStorage/model/QuarantineRule.kt @@ -0,0 +1,41 @@ +package com.cognifide.cogboard.logStorage.model + +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import java.time.Instant + +data class QuarantineRule( + val label: String, + val reasonField: String, + val regex: Regex, + val enabled: Boolean, + val endTimestamp: Instant? +) { + companion object { + private const val LABEL = "label" + private const val REASON = "reasonField" + private const val REGEX = "regExp" + private const val ENABLED = "checked" + private const val END_TIMESTAMP = "endTimestamp" + private val default = QuarantineRule("Default", "", "(?!x)x".toRegex(), false, null) + + fun from(json: JsonObject): QuarantineRule { + return try { + QuarantineRule( + json.getString(LABEL)!!, + json.getString(REASON)!!, + json.getString(REGEX)!!.toRegex(), + json.getBoolean(ENABLED)!!, + json.getLong(END_TIMESTAMP)?.let { Instant.ofEpochSecond(it) } + ) + } catch (_: NullPointerException) { + default + } + } + + fun from(array: JsonArray): List = + array + .mapNotNull { it as? JsonObject } + .map { from(it) } + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/SSHClient.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/SSHClient.kt new file mode 100644 index 000000000..47f16d724 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/SSHClient.kt @@ -0,0 +1,91 @@ +package com.cognifide.cogboard.ssh + +import com.cognifide.cogboard.CogboardConstants +import com.cognifide.cogboard.ssh.auth.SSHAuthData +import com.cognifide.cogboard.ssh.session.SessionStrategyFactory +import com.jcraft.jsch.ChannelExec +import com.jcraft.jsch.JSch +import com.jcraft.jsch.JSchException +import com.jcraft.jsch.Session +import io.vertx.core.buffer.Buffer +import io.vertx.core.json.JsonObject +import io.vertx.core.logging.Logger +import io.vertx.core.logging.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.nio.charset.Charset + +class SSHClient(private val config: JsonObject) { + private var session: Session? = null + private var jsch: JSch? = null + + private fun openSession() { + val authData = SSHAuthData(config) + val jsch = JSch() + val session = SessionStrategyFactory(jsch).create(authData).initSession() + session.setConfig("StrictHostKeyChecking", "no") + try { + session.connect(CogboardConstants.Props.SSH_TIMEOUT) + } catch (exception: JSchException) { + LOGGER.error("Cannot connect to SSH server: $exception") + } + this.session = session + this.jsch = jsch + } + + fun closeSession() { + session?.disconnect() + session = null + jsch = null + } + + fun execute(command: String): String? { + if (session == null || jsch == null) { + openSession() + } + if (session?.isConnected != true) { + return null + } + val (channel, inputStream) = createChannel(command) ?: return null + + return try { + readResponse(inputStream) + } catch (e: IOException) { + e.message + } finally { + channel.disconnect() + } + } + + fun executeAndClose(command: String): String? { + val result = execute(command) + closeSession() + return result + } + + private fun createChannel(command: String): Pair? { + val session = session ?: return null + val channel = session.openChannel("exec") as ChannelExec + channel.setCommand(command) + channel.inputStream = null + val inputStream = channel.inputStream + channel.connect(CogboardConstants.Props.SSH_TIMEOUT) + return Pair(channel, inputStream) + } + + private fun readResponse(stream: InputStream): String? { + val responseBuffer = Buffer.buffer() + val tmpBuf = ByteArray(BUFFER_SIZE) + var readBytes = stream.read(tmpBuf, 0, BUFFER_SIZE) + while (readBytes != -1) { + responseBuffer.appendBytes(tmpBuf, 0, readBytes) + readBytes = stream.read(tmpBuf, 0, BUFFER_SIZE) + } + return responseBuffer.toString(Charset.defaultCharset()) + } + + companion object { + private val LOGGER: Logger = LoggerFactory.getLogger(SSHClient::class.java) + private const val BUFFER_SIZE: Int = 512 + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/AuthenticationType.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/AuthenticationType.kt new file mode 100644 index 000000000..1dfa64105 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/AuthenticationType.kt @@ -0,0 +1,6 @@ +package com.cognifide.cogboard.ssh.auth + +enum class AuthenticationType { + BASIC, + SSH_KEY +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/SSHAuthData.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/SSHAuthData.kt new file mode 100644 index 000000000..35bcc26e9 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/SSHAuthData.kt @@ -0,0 +1,57 @@ +package com.cognifide.cogboard.ssh.auth + +import com.cognifide.cogboard.CogboardConstants.Props +import com.cognifide.cogboard.ssh.auth.AuthenticationType.BASIC +import com.cognifide.cogboard.ssh.auth.AuthenticationType.SSH_KEY +import io.vertx.core.json.Json +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import java.net.URI + +class SSHAuthData(config: JsonObject) { + private val id = config.getString(Props.ID, "") + val user: String = config.getString(Props.USER, "") + val password: String = config.getString(Props.PASSWORD, "") + var key = config.getString(Props.SSH_KEY, "") + private set + val host: String + val port: Int + val authenticationType = fromConfigAuthenticationType(config) + + init { + val uriString = config.getJsonObject(Props.ENDPOINT_LOADED)?.getString(Props.URL) ?: "" + val uri = URI.create(uriString) + host = uri.host + port = uri.port + if (authenticationType == SSH_KEY) { + prepareForSSHKeyUsage() + } + } + + private fun fromConfigAuthenticationType(config: JsonObject): AuthenticationType { + val authTypes = config.getString(Props.AUTHENTICATION_TYPES)?.let { + Json.decodeValue(it) } ?: JsonArray() + + return (authTypes as JsonArray) + .map { AuthenticationType.valueOf(it.toString()) } + .firstOrNull { hasAuthTypeCorrectCredentials(it) } ?: BASIC + } + + private fun hasAuthTypeCorrectCredentials(authType: AuthenticationType): Boolean = + when { + authType == SSH_KEY && key.isNotBlank() -> true + else -> authType == BASIC && user.isNotBlank() && password.isNotBlank() + } + + private fun prepareForSSHKeyUsage() { + val fileHelper = SSHKeyFileHelper(id, key) + fileHelper.saveToFile() + key = fileHelper.path + } + + fun getAuthenticationString(): String = + when (authenticationType) { + BASIC -> password + SSH_KEY -> key + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/SSHKeyFileHelper.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/SSHKeyFileHelper.kt new file mode 100644 index 000000000..51bfa378c --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/auth/SSHKeyFileHelper.kt @@ -0,0 +1,27 @@ +package com.cognifide.cogboard.ssh.auth + +import java.io.File + +class SSHKeyFileHelper( + private val id: String, + private val key: String +) { + private lateinit var file: File + lateinit var path: String + private set + + fun saveToFile() { + getOrCreateFile() + file.writeText(key) + } + + private fun getOrCreateFile() { + path = determineFilepath() + file = File(path) + path = file.absolutePath + } + + private fun determineFilepath() = + if (File("/data").exists()) "/data/$id" + else "${System.getProperty("user.dir")}/../mnt/$id" +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/SessionStrategyFactory.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/SessionStrategyFactory.kt new file mode 100644 index 000000000..cc7df076e --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/SessionStrategyFactory.kt @@ -0,0 +1,21 @@ +package com.cognifide.cogboard.ssh.session + +import com.cognifide.cogboard.ssh.auth.AuthenticationType.BASIC +import com.cognifide.cogboard.ssh.auth.AuthenticationType.SSH_KEY +import com.cognifide.cogboard.ssh.auth.SSHAuthData +import com.cognifide.cogboard.ssh.session.strategy.BasicAuthSessionStrategy +import com.cognifide.cogboard.ssh.session.strategy.SSHKeyAuthSessionStrategy +import com.cognifide.cogboard.ssh.session.strategy.SessionStrategy +import com.jcraft.jsch.JSch + +class SessionStrategyFactory(private val jsch: JSch) { + fun create(authData: SSHAuthData): SessionStrategy = + when (authData.authenticationType) { + BASIC -> { + BasicAuthSessionStrategy(jsch, authData) + } + SSH_KEY -> { + SSHKeyAuthSessionStrategy(jsch, authData) + } + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/BasicAuthSessionStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/BasicAuthSessionStrategy.kt new file mode 100644 index 000000000..119d0293a --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/BasicAuthSessionStrategy.kt @@ -0,0 +1,15 @@ +package com.cognifide.cogboard.ssh.session.strategy + +import com.cognifide.cogboard.ssh.auth.SSHAuthData +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session + +class BasicAuthSessionStrategy(jsch: JSch, authData: SSHAuthData) : SessionStrategy(jsch, authData) { + + override fun initSession(): Session { + val session = jsch.getSession(authData.user, authData.host, authData.port) + session.setPassword(securityString) + + return session + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/SSHKeyAuthSessionStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/SSHKeyAuthSessionStrategy.kt new file mode 100644 index 000000000..38133eeca --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/SSHKeyAuthSessionStrategy.kt @@ -0,0 +1,20 @@ +package com.cognifide.cogboard.ssh.session.strategy + +import com.cognifide.cogboard.ssh.auth.SSHAuthData +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import io.netty.util.internal.StringUtil.EMPTY_STRING + +class SSHKeyAuthSessionStrategy(jSch: JSch, authData: SSHAuthData) : SessionStrategy(jSch, authData) { + override fun initSession(): Session { + if (authData.password == EMPTY_STRING) { + jsch.addIdentity(securityString) + } else { + jsch.addIdentity(securityString, authData.password) + } + val session = jsch.getSession(authData.user, authData.host, authData.port) + session.setConfig("PreferredAuthentications", "publickey") + + return session + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/SessionStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/SessionStrategy.kt new file mode 100644 index 000000000..ee61acd1e --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/ssh/session/strategy/SessionStrategy.kt @@ -0,0 +1,12 @@ +package com.cognifide.cogboard.ssh.session.strategy + +import com.cognifide.cogboard.ssh.auth.SSHAuthData +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session + +abstract class SessionStrategy(protected val jsch: JSch, protected val authData: SSHAuthData) { + protected val securityString: String + get() = authData.getAuthenticationString() + + abstract fun initSession(): Session +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/WidgetIndex.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/WidgetIndex.kt index 70e8e7992..ee5235c1b 100644 --- a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/WidgetIndex.kt +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/WidgetIndex.kt @@ -20,6 +20,7 @@ import com.cognifide.cogboard.widget.type.WorldClockWidget import com.cognifide.cogboard.widget.type.randompicker.RandomPickerWidget import com.cognifide.cogboard.widget.type.sonarqube.SonarQubeWidget import com.cognifide.cogboard.widget.type.zabbix.ZabbixWidget +import com.cognifide.cogboard.widget.type.logviewer.LogViewerWidget import io.vertx.core.Vertx import io.vertx.core.json.JsonArray import io.vertx.core.json.JsonObject @@ -48,7 +49,8 @@ class WidgetIndex { "Jira Buckets" to JiraBucketsWidget::class.java, "Service Check" to ServiceCheckWidget::class.java, "SonarQube" to SonarQubeWidget::class.java, - "White Space" to WhiteSpaceWidget::class.java + "White Space" to WhiteSpaceWidget::class.java, + "Log Viewer" to LogViewerWidget::class.java, ) fun availableWidgets(): JsonArray { diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/ConnectionStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/ConnectionStrategy.kt new file mode 100644 index 000000000..716e64fdf --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/ConnectionStrategy.kt @@ -0,0 +1,18 @@ +package com.cognifide.cogboard.widget.connectionStrategy + +import com.cognifide.cogboard.CogboardConstants +import com.cognifide.cogboard.http.auth.AuthenticationType +import io.vertx.core.json.JsonObject + +abstract class ConnectionStrategy { + protected fun JsonObject.endpointProp(prop: String): String { + return this.getJsonObject(CogboardConstants.Props.ENDPOINT_LOADED)?.getString(prop) ?: "" + } + + protected open fun authenticationTypes(): Set { + return setOf(AuthenticationType.BASIC) + } + + abstract fun getNumberOfLines(): Long? + abstract fun getLogs(skipFirstLines: Long?): Collection +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/ConnectionStrategyFactory.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/ConnectionStrategyFactory.kt new file mode 100644 index 000000000..e9ea04ace --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/ConnectionStrategyFactory.kt @@ -0,0 +1,19 @@ +package com.cognifide.cogboard.widget.connectionStrategy + +import io.vertx.core.json.JsonObject +import java.net.URI + +class ConnectionStrategyFactory( + private val config: JsonObject, + private val address: String +) { + + fun build(): ConnectionStrategy { + return when (URI.create(address).scheme) { + "ssh" -> SSHConnectionStrategy(config) + else -> throw UnknownConnectionTypeException("Connection type not supported") + } + } +} + +class UnknownConnectionTypeException(message: String?) : RuntimeException(message) diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/SSHConnectionStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/SSHConnectionStrategy.kt new file mode 100644 index 000000000..4d5d924dd --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/connectionStrategy/SSHConnectionStrategy.kt @@ -0,0 +1,56 @@ +package com.cognifide.cogboard.widget.connectionStrategy + +import com.cognifide.cogboard.CogboardConstants.Props +import com.cognifide.cogboard.ssh.SSHClient +import io.vertx.core.json.Json +import io.vertx.core.json.JsonObject +import com.cognifide.cogboard.ssh.auth.AuthenticationType + +class SSHConnectionStrategy(val config: JsonObject) : ConnectionStrategy() { + + override fun authenticationTypes(): Set { + return setOf(AuthenticationType.BASIC, AuthenticationType.SSH_KEY) + } + + override fun getNumberOfLines(): Long? { + val logFilePath = config.getString(Props.PATH) ?: return null + + return SSHClient(prepareConfig(config)) + .executeAndClose("wc -l < $logFilePath") + ?.trim() + ?.toLongOrNull() + } + + override fun getLogs(skipFirstLines: Long?): Collection { + val logFilePath = config.getString(Props.PATH) ?: return emptyList() + val command = skipFirstLines?.let { "tail -n +${it + 1} $logFilePath" } ?: "cat $logFilePath" + + return SSHClient(prepareConfig(config)) + .executeAndClose(command) + ?.trim() + ?.lines() + ?: emptyList() + } + + private fun prepareConfig(config: JsonObject): JsonObject { + val tmpConfig = prepareConfigLines(config, + Props.USER, + Props.PASSWORD, + Props.TOKEN, + Props.SSH_HOST, + Props.SSH_KEY, + Props.SSH_KEY_PASSPHRASE + ) + + tmpConfig.getString(Props.AUTHENTICATION_TYPES) + ?: config.put(Props.AUTHENTICATION_TYPES, Json.encode(authenticationTypes())) + return tmpConfig + } + + private fun prepareConfigLines(config: JsonObject, vararg fields: String): JsonObject { + for (field in fields) { + config.getString(field) ?: config.put(field, config.endpointProp(field)) + } + return config + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/LogViewerWidget.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/LogViewerWidget.kt new file mode 100644 index 000000000..ceec5f6cd --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/LogViewerWidget.kt @@ -0,0 +1,128 @@ +package com.cognifide.cogboard.widget.type.logviewer + +import com.cognifide.cogboard.CogboardConstants.Props +import com.cognifide.cogboard.config.service.BoardsConfigService +import com.cognifide.cogboard.logStorage.LogStorage +import com.cognifide.cogboard.storage.ContentRepository +import com.cognifide.cogboard.widget.BaseWidget +import com.cognifide.cogboard.widget.Widget +import com.cognifide.cogboard.widget.connectionStrategy.ConnectionStrategy +import com.cognifide.cogboard.widget.connectionStrategy.ConnectionStrategyFactory +import com.cognifide.cogboard.widget.connectionStrategy.UnknownConnectionTypeException +import com.cognifide.cogboard.widget.type.logviewer.logparser.LogParserStrategyFactory +import io.vertx.core.Vertx +import io.vertx.core.eventbus.MessageConsumer +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import com.cognifide.cogboard.logStorage.model.LogStorageConfiguration +import com.cognifide.cogboard.logStorage.model.QuarantineRule + +class LogViewerWidget( + vertx: Vertx, + config: JsonObject, + serv: BoardsConfigService +) : BaseWidget(vertx, config, serv) { + private val contentRepository: ContentRepository = ContentRepository.DEFAULT + private val address = config.endpointProp(Props.URL) + private lateinit var consumer: MessageConsumer + private val connectionStrategy: ConnectionStrategy? = determineConnectionStrategy() + private val logStorage: LogStorage? = connectionStrategy?.let { + LogStorage( + buildConfiguration(config), + it, + determineLogParsingStrategy() + ) + } + + init { + // Create a handler for updating the state of the widget. + createDynamicChangeSubscriber()?.handler { newState -> + newState?.body()?.let { + contentRepository.save(id, it) + logStorage?.rules = rules + logStorage?.filterExistingLogs() + updateWidget(false) + } + } + } + + override fun start(): Widget { + vertx.deployVerticle(logStorage) + consumer = vertx.eventBus() + .consumer(eventBusAddress) + .handler { logs -> + logs?.body()?.let { sendResponse(it) } + } + return super.start() + } + + override fun stop(): Widget { + logStorage?.delete() + logStorage?.deploymentID()?.let { vertx.undeploy(it) } + consumer.unregister() + return super.stop() + } + + override fun updateState() { + updateWidget(true) + } + + /** Updates the contents of the widget (optionally fetching new logs when [fetchNewLogs] is true). */ + private fun updateWidget(fetchNewLogs: Boolean) { + if (address.isNotBlank()) { + logStorage?.rules = rules + logStorage?.updateLogs(fetchNewLogs) + } else { + sendConfigurationError("Endpoint URL is blank") + } + } + + /** Sends the updated state to the client. */ + private fun sendResponse(logs: JsonObject) { + val rules = contentRepository.get(id).getJsonArray(QUARANTINE_RULES) ?: JsonArray() + logs.put(QUARANTINE_RULES, rules) + send(logs) + } + + /** Gets the quarantine rules from the content repository. */ + private val rules: List + get() = contentRepository + .get(id) + .getJsonArray(QUARANTINE_RULES) + ?.let { QuarantineRule.from(it) } ?: emptyList() + + private fun buildConfiguration(config: JsonObject): LogStorageConfiguration { + return LogStorageConfiguration( + config.getString(Props.ID) ?: DEFAULT_ID, + config.getLong(Props.LOG_LINES) ?: DEFAULT_LOG_LINES, + config.getLong(Props.LOG_FILE_SIZE) ?: DEFAULT_LOG_FILE_SIZE, + config.getLong(Props.LOG_EXPIRATION_DAYS) ?: DEFAULT_LOG_EXPIRATION_DAYS, + eventBusAddress + ) + } + + private fun determineConnectionStrategy(): ConnectionStrategy? { + return try { + ConnectionStrategyFactory(config, address) + .build() + } catch (e: UnknownConnectionTypeException) { + sendConfigurationError("Unknown endpoint type") + null + } + } + + private fun determineLogParsingStrategy() = + LogParserStrategyFactory() + .build(config.getString(Props.LOG_PARSER) + ?: LogParserStrategyFactory.Type.DEFAULT.toString() + ) + + companion object { + private const val DEFAULT_ID = "0" + const val DEFAULT_LOG_LINES = 1000L + private const val DEFAULT_LOG_FILE_SIZE = 50L + private const val DEFAULT_LOG_EXPIRATION_DAYS = 5L + + private const val QUARANTINE_RULES = "quarantineRules" + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/LogsViewer.md b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/LogsViewer.md new file mode 100644 index 000000000..7d723cac6 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/LogsViewer.md @@ -0,0 +1,345 @@ +# Logs Viewer widget + +## Classes +### LogViewerWidget +Main widget class, inheriting from BasicWidget. + +#### Constructor +An eventBus listener is created that handles sending logs upon launching widget by the user. +#### Non-inherited methods +- `updateWidget(fetchNewLogs: boolean)` + +Request logs from LogStorage is address was provided in widget configuration. +New logs should be fetched by LogStorage if `fetchNewLogs` is set to true + +- `sendResponse(logs: JsonObject)` + +Sends quarantine rules to front-end. + +- `buildConfiguration(config: JsonObject): LogStorageConfiguration` + +Transforms widget configuration `JsonObject` to `LogStorageConfiguration` by passing chosen fields +from config. + +- `determineConnectionStrategy(): ConnectionStrategy?` + +Uses `ConnectionStrategyFactory` to get correct strategy that will be used to connect +with endpoint providing logs. + +- `determineLogParsingStrategy()` + +Uses `LogParsingStrategyFactory` to get proper parser for logs, based on `LOG_PARSER` field from +configuration of the widget. + +### LogStorage +Class managing connection with database - it fetches logs from it and updates with new logs +delivered by `ConnectionStrategy` passed in constructor. + +#### Fields +- `enabledRegexes: List` + +Returns regexes of quarantine rules that are enabled at the moment. + +- `logsCollection: MongoCollection` + +gets collection of logs associated to this widget from mongoDB + +- `collectionState: LogCollectionState?` + +Returns a logs collection configuration associated with this widget (if present). + +#### Methods +- `saveState(state: LogCollectionState?)` + +Saves new state of widget or deletes it from database if no state is passed as argument + +- `deleteAllLogs()` + +Deletes all logs from collection associated with given widget. + +- `deleteFirstLogs(n: Long)` + +Deletes given number of logs starting from lowest `seq` value in collection. +Used to keep configured number of logs in the database + +- `deleteOldLogs()` + +Deletes logs that have been in the database for longer than the configured validity time. + +- `deleteSpaceConsumingLogs()` + +Computes how many logs have to be deleted to keep collection size under configured max value. +Then uses `deleteFirstLogs` method to get rid of them. + +- `filter(logs: MutableList)` + +Deletes logs meeting the quarantine criteria from list of newly downloaded entries. + +- `filterExistingLogs()` + +Deletes entries from collection that meet the quarantine criteria. + +- `downloadFilterLogs(skipFirstLines: Long? = null): List` + +Downloads new logs from remote server, skipping number of lines passed as a parameter - in our use case it is +the number of the last downloaded line in the previous connection to the remote. Received logs are also +filtered using `filter` method. + +- `insertLogs(seq: Long, logs: Collection)` + +Used to save received `logs` to collection associated with current widget instance. + +`seq` is used to index logs in database in order of their appearance in remote log file. + +- `downloadLogs(): List` + +Checks collection state for number of last line downloaded and last `seq` value assigned. +Then logs are downloaded using `downloadFilterLogs` method and added to database by calling `insertLogs`. + +- `prepareResponse(insertedLogs: List): JsonObject` + +Prepares logs for sending them to frontend and adds `variableFields` from parser to give frontend information +how to display the logs according to how they were parsed. + +- `updateLogs(fetchNewLogs: Boolean)` + +Downloads new logs if `fetchNewLogs` is set to `true` and sends data returned by `prepareResponse` method to frontend + +- `delete()` + +Deletes all data associated with used widget instance. + +### LogParserStrategy +Interface for building new parsers of logs received from the server. +#### Fields +- `variableFields: List` + +Stores list of fields that are parsed out of log line, different from required fields of `date` and `type` + +#### Methods +- `parseLine(line: String): Log` + +Transforms line from log file to instance of `Log` class. This method should capture data for mandatory fields: +`date` and `type`, but also for fields stored in `variableFields` list. + +### LogParserStrategyFactory +Factory class used to create instances of classes implementing `LogParserStrategy` +#### Inner class +- `enum class Type` + +Used to distinguish what type of parser should be created. When implementing new parser, new entry should be +added to this enumerator. + +#### Methods +- `build(typeStr: String): LogParserStrategy` + +Creates instance of parser, based on the passed `typeStr`. Passed parameter value should match name of any entry +in `Type` enumerator. If value doesn't match, `UnknownParserTypeException` is thrown. + +### ConnectionStrategy +Abstract class used to connect to the server and retrieve new lines of logs or number of lines in remote log file. +By using references to this class, rest of the application doesn't have to know what method is used for +obtaining the data. + +#### Methods +- `authenticationTypes(): Set` + +Method should return types of authentication that can be used by this strategy. Values returned should be entries +of enum classes such as `AuthenticationType` from package `com.cognifide.cogboard.http.auth` + +- `getNumberOfLines(): Long?` + +Implementations should fetch information from remote server about amount of lines in monitored log file. + +- `getLogs(skipFirstLines: Long?): Collection` + +Implementations should download logs from monitored file, optionally skipping given number of first lines e.g. +if log file has 200 lines and `skipFirstLines` is set to 50, only lines 51 to 200 should be downloaded. + +### SSHConnectionStrategy +Implementation of `ConnectionStrategy` class. Uses SSH protocol to connect to server. +#### Overriden methods +- `authenticationTypes(): Set` + +This strategy allows two types of authentication: + +`BASIC` - username + password + +`SSH_KEY` - username + private SSH key + +- `getNumberOfLines(): Long?` + +Bash command `wc` is executed on the server by instance of `SSHClient` class + +- `getLogs(skipFirstLines: Long?): Collection` + +Bash command `tail` or `cat` is executed on the server by `SSHClient` instance, depending on whether non-null +value was passed in parameter `skipFirstLines`. + +#### Other methods +- `prepareConfig(config: JsonObject): JsonObject` + +Ensures that required fields are added to `config` JsonObject. Calls `prepareConfigLines` method. + +- `prepareConfigLines(config: JsonObject, vararg fields: String): JsonObject` + +Iterates over values passed as `fields` and checks if field of that name is present in `config` object - if not +then it's taken from `endpoint.loaded` field, which stores info about remote that widget connects to. + +### ConnectionStrategyFactory +Factory class that creates needed `ConnectionStrategy` implementation based on `address` passed in constructor. + +#### Methods +- `build(): ConnectionStrategy` + +Returns created `ConnectionStrategy` implementation which is created based on `scheme` from instance of `URI` +class created by passing `address` to its static `create` method. Upon adding support for new protocol of +communication, value of `scheme` for that protocol should be added to this method's `when` expression + +### SSHClient +Class responsible for connecting and executing commands on remote server via SSH + +#### Fields +- session: Session? + +Field for storing object representing SSH session established between server and remote containing the logs. + +- jsch: JSch? + +Field storing instance of main class for using `JSch` library, managing SSH connection. + +#### Methods +- openSession() + +Populates fields `session` and `jsch` with proper data by creating `JSch` instance and delegating establishing of +`Session` to proper `SessionStrategy`. + +- closeSession() + +Closes connection to remote server and removes references to objects from `session` and `jsch` fields + +- execute(command: String): String? + +Executes bash command on remote server, passed in `command` parameter. + +- executeAndClose(command: String): String? + +Calls `execute` method and closes session via call to `closeSession` + +- createChannel(command: String): Pair? + +Opens channel used for executing commands on remote server. After execution of command it returns created +`ChannelExec` object and `InputStream` later used for reading the response. + +- readResponse(stream: InputStream): String? + +Reads from `stream` and returns the response. + +### SessionStrategy +Abstract class used for proper authentication via SSH, depending on available user data. + +#### Fields +- jsch: JSch + +Instance `JSch` managing current SSH connection. + +- authData: SSHAuthData + +Container with data used for authentication on the remote server. + +- securityString: String + +Retrieves string used for authentication, such as password or SSH key, from `authData` + +#### Methods +- initSession(): Session + +Method used to properly authenticate via SSH on the remote. + +### BasicAuthSessionStrategy +Authenticates on the server using username and password. + +### SSHKeyAuthSessionStrategy +Authenticates on the server using username and SSH key. + +### SSHAuthData +Class used to retrieve and store data later used to authenticate on the server. + +#### Fields +- id: String + +Id of the widget connecting to the server via SSH + +- user: String + +Username used to authenticate on the server. + +- password: String + +Password to authenticate on the server. Might be an empty string. + +- key: String + +SSH key used to authorize on the server. Might be an empty string. + +- host: String + +Hostname to connect to via SSH + +- port: Int + +Port to connect to via SSH + +- authenticationType: AuthenticationType + +Determines whether connection will be authenticated via username and password or via SSH key + +#### Methods +- fromConfigAuthenticationType(config: JsonObject): AuthenticationType + +Returns `AuthenticationType` value based on `authenticationTypes` field in config + +- hasAuthTypeCorrectCredentials(authType: AuthenticationType): Boolean + +Checks whether proper credentials were provided for chosen `AuthenticationType` + +- prepareForSSHKeyUsage() + +Saves SSH key from endpoint credentials to file to be used by Jsch library. + +- getAuthenticationString(): String + +Returns proper string to be used for authentication alongside username. + +### SSHKeyFileHelper +Class used for managing the process of saving SSH key from property in JsonObject + +#### Fields +- id: String + +Id of widget establishing the connection. + +- key: String + +SSH key to be saved to file + +- file: File + +Object representing file to which SSH key will be saved + +- path: String + +Path to the file with SSH key + +#### Methods +- saveToFile() + +Method which saves the SSH key to the file + +- getOrCreateFile() + +Creates of fetches the reference to the file to which the SSH key will be saved + +- determineFilepath() + +Determines where to look for the file containing SSH key \ No newline at end of file diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/DefaultLogParserStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/DefaultLogParserStrategy.kt new file mode 100644 index 000000000..7ba364989 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/DefaultLogParserStrategy.kt @@ -0,0 +1,53 @@ +package com.cognifide.cogboard.widget.type.logviewer.logparser + +import com.cognifide.cogboard.logStorage.model.Log +import com.cognifide.cogboard.logStorage.model.LogVariableData +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class DefaultLogParserStrategy : LogParserStrategy { + override val variableFields = listOf("Provider", "Message") + + private val regex = """^(?<$DATE>[0-9-:]+) \*(?<$TYPE>[A-Z]+)\* \[(?<$PROVIDER>[a-zA-Z]+)\][ ]+(?<$MESSAGE>.+)$""" + .toRegex() + + override fun parseLine(line: String): Log { + val groups = regex.matchEntire(line.trim())?.groups ?: return makeParsingErrorLog(line) + + try { + val date = LocalDateTime + .parse(groups[DATE]!!.value, dateTimeFormatter) + .toEpochSecond(ZoneOffset.UTC) + val type = groups[TYPE]!!.value + val provider = groups[PROVIDER]!!.value + val message = groups[MESSAGE]!!.value + + val variableData = listOf( + LogVariableData(provider, "No description"), + LogVariableData(message, "No message description") + ) + + return Log(date = date, type = type, variableData = variableData) + } catch (_: NullPointerException) { + return makeParsingErrorLog(line) + } + } + + companion object { + private val dateTimeFormatter = DateTimeFormatter.ofPattern("u-M-d:H:m:s") + private const val DATE = "date" + private const val TYPE = "type" + private const val PROVIDER = "provider" + private const val MESSAGE = "message" + + private fun makeParsingErrorLog(line: String): Log = Log( + date = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC), + type = "ERROR", + variableData = listOf( + LogVariableData("DefaultLogParserStrategy", "No description"), + LogVariableData("Cannot parse a log", "Line causing the error: $line") + ) + ) + } +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/LogParserStrategy.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/LogParserStrategy.kt new file mode 100644 index 000000000..d79bb269f --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/LogParserStrategy.kt @@ -0,0 +1,8 @@ +package com.cognifide.cogboard.widget.type.logviewer.logparser + +import com.cognifide.cogboard.logStorage.model.Log + +interface LogParserStrategy { + val variableFields: List + fun parseLine(line: String): Log +} diff --git a/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/LogParserStrategyFactory.kt b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/LogParserStrategyFactory.kt new file mode 100644 index 000000000..7520f74e6 --- /dev/null +++ b/cogboard-app/src/main/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/LogParserStrategyFactory.kt @@ -0,0 +1,19 @@ +package com.cognifide.cogboard.widget.type.logviewer.logparser + +class LogParserStrategyFactory { + enum class Type { + DEFAULT + } + + fun build(typeStr: String): LogParserStrategy { + return try { + when (Type.valueOf(typeStr.toUpperCase())) { + Type.DEFAULT -> DefaultLogParserStrategy() + } + } catch (e: IllegalArgumentException) { + throw UnknownParserTypeException("Unknown log parsing type") + } + } +} + +class UnknownParserTypeException(message: String) : RuntimeException(message) diff --git a/cogboard-app/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory b/cogboard-app/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory index a41fb2c60..1d7c36261 100644 --- a/cogboard-app/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory +++ b/cogboard-app/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory @@ -4,4 +4,5 @@ com.cognifide.cogboard.widget.handler.UpdateWidget com.cognifide.cogboard.widget.handler.DeleteWidget com.cognifide.cogboard.widget.handler.ContentUpdateWidget com.cognifide.cogboard.security.LoginHandler -com.cognifide.cogboard.security.SessionHandler \ No newline at end of file +com.cognifide.cogboard.security.SessionHandler +com.cognifide.cogboard.logStorage.LogController \ No newline at end of file diff --git a/cogboard-app/src/main/resources/initData/admin.json b/cogboard-app/src/main/resources/initData/admin.json index 3fbc3f7fe..dd8939d32 100644 --- a/cogboard-app/src/main/resources/initData/admin.json +++ b/cogboard-app/src/main/resources/initData/admin.json @@ -1,4 +1,4 @@ -{ - "user": "admin", - "password": "admin" -} +{ + "user": "admin", + "password": "admin" +} diff --git a/cogboard-app/src/main/resources/initData/config.json b/cogboard-app/src/main/resources/initData/config.json index f89cd6272..98fc815c9 100644 --- a/cogboard-app/src/main/resources/initData/config.json +++ b/cogboard-app/src/main/resources/initData/config.json @@ -143,10 +143,21 @@ "switchInterval": 100, "title": "QA/DEV board", "type": "WidgetBoard" + }, + "board-7d6e23ea-78a1-4f89-a5d2-47c499f9657d": { + "autoSwitch": true, + "switchInterval": 60, + "id": "board-7d6e23ea-78a1-4f89-a5d2-47c499f9657d", + "theme": "default", + "widgets": ["widget95"], + "columns": 4, + "title": "Log Viewer", + "type": "WidgetBoard" } }, "allBoards": [ "board-9ebbc895-41e7-45b3-b6be-2e88b8dd4e5b", + "board-7d6e23ea-78a1-4f89-a5d2-47c499f9657d", "board-acdc6ecc-2bcd-4f8f-80a3-5db8a5db8394", "board-b93ed3ae-698c-43d3-be8c-06331560e65a", "board-7fd129fd-9f2e-42ee-abc4-a1299c3dadc1", @@ -1950,6 +1961,27 @@ "installedThreshold": 2, "excludedBundles": "", "expandContent": false + }, + "widget95": { + "id": "widget95", + "title": "SSH Mock Server Logs", + "config": { + "columns": 4, + "goNewLine": false, + "rows": 3 + }, + "type": "LogViewerWidget", + "disabled": false, + "content": {}, + "isUpdating": false, + "boardId": "board-7d6e23ea-78a1-4f89-a5d2-47c499f9657d", + "endpoint": "endpoint6", + "schedulePeriod": 60, + "path": "/home/mock/example.txt", + "logLinesField": 300, + "logFileSizeField": 50, + "logRecordExpirationField": 5, + "logParserField": "default" } } } diff --git a/cogboard-app/src/main/resources/initData/credentials.json b/cogboard-app/src/main/resources/initData/credentials.json index db175e834..836427659 100644 --- a/cogboard-app/src/main/resources/initData/credentials.json +++ b/cogboard-app/src/main/resources/initData/credentials.json @@ -2,10 +2,29 @@ "credentials": [ { "token": "", + "sshKey": "", "password": "admin", "user": "admin", "label": "Zabbix", "id": "credential1" + }, + { + "sshKeyPassphrase": "", + "sshKey": "", + "token": "", + "password": "TLQuoLMn*T89&Y*r*YqHviSFH6MkR!4E", + "user": "mock", + "label": "SSH Cred", + "id": "credential4" + }, + { + "sshKeyPassphrase": "", + "sshKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIG4wIBAAKCAYEArja5epueCYkWwrakBEJcwdy82o+MMzYqzL8ScvZUQXjzXdQL\nKqG+pE2Ng8Cldp+/X+APL5qc/kPzDJJYxUhAEV/x/aj/yn2cdTjzRc8cevS1UCWU\nArC8Gm1R6yqhhbvsH81lgNaQYE6OxIiLE5DD/0UqmabeHzvBdOr4lDILk3kjONCW\nhvKAto5zjEi9GRwfDYP2IqxruC9OCY4I1tJNt0gDvL+CCTzpLuZeJVy6iWJAFZGc\nIKw/Zs8tTG7bjojWXDJC1vTz2NezGEay2crta0G/YZ2ak9kOGNHcWBuTw23x4YqX\nja+oA0WpX887cNygTFTX5Q/KzXBjd4m8NruF0phd8iKS3L6NfeZmHoOp1Zo+jk4H\nRrsuSZGn3uRM6xIRTJthYI+33iis7c4aQytq/h0rUjdwUSEppJxf0FjJlkV4iCK1\nsXlT6lKCbEiz4UEKtPvnvCGfT/UPYNnrbLtpy94xyAfSWzPlm5Gqb07Nme5O4d5v\n05f3RDXCpa5CJYB7AgMBAAECggGAUKVgo1Nai0t8z9JAhwA5dDy85+g/nI1crr9c\nyP8i7dQRxMOeD7QkTmbgNbd+YTV+H+HW5dCLEGFgJ9evZFQX5HMn0KblElWnkdQ1\nOYGwy3JwZJOgusYZrZohq91mPERMAETS0huBZjO3f18+EmaXdJoOKGbIuGivG3KS\nc/fex/vxxCE7LWkhEGFNOAmMEA2milkmHdL3YqHzXBT2HovoEgoyQLPefGxH/cAC\noQUWDPcAd3uabL2P9AXAEHvZA1hwL0I0x3wxJH9YcAf97kAT4//0ImhOqr7Nu69b\nt8ukP0uyqHE4zFMbbVVJYfqwtduHXt6qYQIR8T7W8EynU/jsgsFqBFb9sP0+KpsI\n0GDPVvyiS/kgX1/yp/dyXw25ngo9GRKPCDI+yIotkB4ank38uKEbXc4CHQ9ueZAZ\nw2WkqpUB9HAykPbyOeaEc4T93rLlzqNnFembsRdTc7X0W4nsVvPZXv/pae3OSMED\nORf4j0QQmgiIXyfZ75QkvJ2WC/phAoHBANsSmqIAst38ZjRFyVCCaaGdgMjAusEp\ni2urm/hnjYr5H0PjnLxU3G961qTnOZqT/VJ8jFjPvUCcnFFOW/GTIIW3g+/BSFui\nRlp+vvyj/HqaEWVU46JpG/AItlFT0Ktg2yiKZ/3bABZsc5DqAB0fGGMKNs4cXwwt\nsNTfullQzaRW8pUp3UE+uEWHvyhAZvAIf93+X0k/G1prSLHnIc0FKTs52P5gpyl+\nSfkjAoyRs1fUOd/PQIh2t5+/NAZX39O7WQKBwQDLlF/xtbiT+SWkH06kN9bwCXgC\nDOmnMwIfrmHud5bPiRMvKaTrjD7bbVaKvXSktyI9DALYtsVxRzY4lIVgWAAT3rxK\ntE5vzdwb0TQbXNGomWM13ynQgClEz0g1sAFJ1hO8R1DRBFmV8P8AV80ni4d92GkX\nZHbSwF+shxGudW2J/fGgQkibWkIfnPT3gIi4tjexbg0WT60ZI5neEplpc5dJPF4q\nBiV1Hu660yOKAjtTZSIR1EyTrh/nClNHNJvJo/MCgcEAtuXKWfSRYMnHnl6hG2k1\nvWtcyL43bOs9bjAA8JurzVn9o1VVVtrWivAYYeZ17jsdpI89MSyHCXl2/F6aXo6B\n+YFkUneg7HgHmqf01cInGUilu17rCX4NiBIN/MooDdy4PBmJhqQfZ5k1xsfGPonm\nd1FgviVrqSRAXQlIcCcI+OpqbuRbx4wQlmQl0Portryx3GnxrZpVQOEO+RBJ5Pwp\nFzxNkNqq1PaN1cVH7In8HBigFN3YN9Y9qc4dJiqZQRFJAoHAamhIad9w9a8hVJKk\nmUMyjk50sqWrLyCDOKn+OBW79wgPxfP/ZrrsU+bneCcko7+xHrV7e2i09Mui9Jn0\nyPHWQIyIYIe0A85XARctJCw0zeo2p/7YLUn/yB6MALvZQI2rzRp9jHK4nJ3Vu4kp\nC0Vr8YQ/EeIKFYhFubjzrftk4N6iAAEFUGYx77IrfH5reBiOLah3ILVOpbgtAZ05\nIJwxdC8gjNiflYMwhug7SDR4a9ONpkIQMJSvyiRkePBviUqvAoHAOwnmneFwMpfz\nSzrHDClrhu8+r7f0HLk7e0rI/vQ8LAlFIonsNxZGIgtR0itrZQX/fdwlCnwakYEf\npXmViDS4ftOFuquCO331l9s9oF192aEB8ms+e4FzRVVhHd61U8TaiGU8pJnTw58A\naaMXL+ir+e+XRaAr0+zejZnYkPy9IkBROC/IZeawy9mFpbrW5AhlBfksexqtPlp8\nNNxWGwzQrXuffeTWLJBclPF8IJE6DZIw53UyHoD5Fg4asS9Wc1yV\n-----END RSA PRIVATE KEY-----\n", + "token": "", + "password": "", + "user": "mock", + "label": "SSH Key Credentials", + "id": "credential5" } ] -} \ No newline at end of file +} diff --git a/cogboard-app/src/main/resources/initData/endpoints.json b/cogboard-app/src/main/resources/initData/endpoints.json index 5f13287cb..6fe8fc0d5 100644 --- a/cogboard-app/src/main/resources/initData/endpoints.json +++ b/cogboard-app/src/main/resources/initData/endpoints.json @@ -13,6 +13,13 @@ "publicUrl": "http://api-mocks:8080/zabbix/api_jsonrpc.php", "credentials": "credential1", "id": "endpoint3" + }, + { + "label": "SSH Logs Server", + "url": "ssh://ssh-server:2222", + "publicUrl": "", + "credentials": "credential4", + "id": "endpoint6" } ] } \ No newline at end of file diff --git a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/config/EndpointTest.kt b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/config/EndpointTest.kt index 2705faf5d..8bcb65644 100644 --- a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/config/EndpointTest.kt +++ b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/config/EndpointTest.kt @@ -37,10 +37,15 @@ internal class EndpointTest { assert(validEndpoint.containsKey("user")) assert(validEndpoint.containsKey("password")) assert(validEndpoint.containsKey("token")) + assert(validEndpoint.containsKey("sshKey")) + assert(validEndpoint.containsKey("sshKeyPassphrase")) assert(invalidEndpoint.containsKey("user")) assert(invalidEndpoint.containsKey("password")) assert(invalidEndpoint.containsKey("token")) + assert(invalidEndpoint.containsKey("sshKey")) + assert(invalidEndpoint.containsKey("sshKeyPassphrase")) + } @Test @@ -48,6 +53,9 @@ internal class EndpointTest { assertEquals("user1", validEndpoint.getString("user")) assertEquals("password1", validEndpoint.getString("password")) assertEquals("token1", validEndpoint.getString("token")) + assertEquals("key1", validEndpoint.getString("sshKey")) + assertEquals("pass1", validEndpoint.getString("sshKeyPassphrase")) + } @Test @@ -55,6 +63,8 @@ internal class EndpointTest { assertEquals("", invalidEndpoint.getString("user")) assertEquals("", invalidEndpoint.getString("password")) assertEquals("", invalidEndpoint.getString("token")) + assertEquals("", invalidEndpoint.getString("sshKey")) + assertEquals("", invalidEndpoint.getString("sshKeyPassphrase")) } @Test @@ -67,7 +77,9 @@ internal class EndpointTest { "publicUrl" : "Public Url", "user" : "user1", "password" : "password1", - "token" : "token1" + "token" : "token1", + "sshKey": "key1", + "sshKeyPassphrase" : "pass1" } """) @@ -83,7 +95,9 @@ internal class EndpointTest { "url" : "url", "user" : "", "password" : "", - "token" : "" + "token" : "", + "sshKey" : "", + "sshKeyPassphrase" : "" } """) diff --git a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/logStorage/model/QuarantineRuleTest.kt b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/logStorage/model/QuarantineRuleTest.kt new file mode 100644 index 000000000..d2cdfb007 --- /dev/null +++ b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/logStorage/model/QuarantineRuleTest.kt @@ -0,0 +1,98 @@ +package com.cognifide.cogboard.logStorage.model + +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class QuarantineRuleTest { + @Test + fun `Properly parses correct rule`() { + val rule = QuarantineRule.from(JsonObject(""" + { + "label": "Example label", + "reasonField": "Reason", + "regExp": "^a", + "checked": true, + "endTimestamp": 1641491404 + } + """)) + assertEquals("Example label", rule.label) + assertEquals("Reason", rule.reasonField) + assertEquals("^a", rule.regex.pattern) + assertEquals(true, rule.enabled) + assertEquals(1641491404, rule.endTimestamp?.epochSecond) + } + + @Test + fun `Properly parses a rule without end timestamp`() { + val rule = QuarantineRule.from(JsonObject(""" + { + "label": "Example label", + "reasonField": "Reason", + "regExp": "^a", + "checked": true + } + """)) + assertEquals("Example label", rule.label) + assertEquals("Reason", rule.reasonField) + assertEquals("^a", rule.regex.pattern) + assertEquals(true, rule.enabled) + assertEquals(null, rule.endTimestamp) + } + + @Test + fun `Returns default rule when incorrect`() { + val rule = QuarantineRule.from(JsonObject(""" + { + "Field1": "Example field", + "SecondField": 2, + "Another field": "Lorem ipsum", + "Last field": [] + } + """)) + assertEquals("Default", rule.label) + assertEquals("", rule.reasonField) + assertEquals("(?!x)x", rule.regex.pattern) + assertEquals(false, rule.enabled) + assertEquals(null, rule.endTimestamp) + } + + @Test + fun `Empty JSON array creates empty rules array`() { + val rules = QuarantineRule.from(JsonArray("[]")) + assertEquals(0, rules.size) + } + + @Test + fun `Correctly parses rules in array`() { + val rules = QuarantineRule.from(JsonArray(""" + [ + { + "label":"Example label", + "reasonField":"Reason", + "regExp":"^a", + "checked":true, + "endTimestamp": 1641491404 + }, + { + "label":"Example label", + "reasonField":"Reason", + "regExp":"^a", + "checked":true + } + ] + """)) + assertEquals(2, rules.size) + assertEquals("Example label", rules[0].label) + assertEquals("Reason", rules[0].reasonField) + assertEquals("^a", rules[0].regex.pattern) + assertEquals(true, rules[0].enabled) + assertEquals(1641491404, rules[0].endTimestamp?.epochSecond) + assertEquals("Example label", rules[1].label) + assertEquals("Reason", rules[1].reasonField) + assertEquals("^a", rules[1].regex.pattern) + assertEquals(true, rules[1].enabled) + assertEquals(null, rules[1].endTimestamp) + } +} \ No newline at end of file diff --git a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/ZabbixWidgetTest.kt b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/ZabbixWidgetTest.kt index 59d70d850..09ef9e54f 100644 --- a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/ZabbixWidgetTest.kt +++ b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/ZabbixWidgetTest.kt @@ -1,115 +1,115 @@ -package com.cognifide.cogboard.widget.type - -import com.cognifide.cogboard.widget.type.zabbix.ZabbixWidget -import io.vertx.core.json.JsonArray -import io.vertx.core.json.JsonObject -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.junit.jupiter.MockitoExtension -import com.cognifide.cogboard.TestHelper.Companion.readConfigFromResource as load - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -@ExtendWith(MockitoExtension::class) -class ZabbixWidgetTest : WidgetTestBase() { - - private lateinit var zabbixWidgetTest: ZabbixWidget - - override fun widgetName(): String = zabbixWidgetTest.javaClass.simpleName - - override fun initWidget(): JsonObject = super.initWidget() - .put(RANGE, JsonArray().add(20).add(60)) - .put(MAX_VALUE, 100) - - @BeforeEach - fun initTest() { - super.init() - } - - @Test - fun `Expect ok widget update message send on event bus`() { - initZabbixWidgetWithMetric(discUsedMetric) - val (result, content) = handleResponse(jsonFileDiscUsedMetric) - - assertUpdateDatePresent(result) - assertLastValue("7254701317", content) - assertName("Used disk space on \$1", content) - assertStatus("OK", result) - assertHistory("7254701317", "1602331216799", content) - } - - @Test - fun `Expect warn widget update message send on event bus`() { - initZabbixWidgetWithMetric(heapMetric) - val (result, content) = handleResponse(jsonFileHeapMetric) - - assertUpdateDatePresent(result) - assertLastValue("50744555432", content) - assertName("mem heap size", content) - assertStatus("UNSTABLE", result) - assertHistory("50744555432", "1602486782740", content) - } - - @Test - fun `Expect fail widget update message send on event bus`() { - initZabbixWidgetWithMetric(cpuMetric) - val (result, content) = handleResponse(jsonFileCpuMetric) - - assertUpdateDatePresent(result) - assertLastValue("63", content) - assertName("CPU \$2 time", content) - assertStatus("FAIL", result) - assertHistory("63", "1602331143732", content) - } - - @Test - fun `Expect unknown widget update message send on event bus`() { - initZabbixWidgetWithMetric(uptimeMetric) - val (result, content) = handleResponse(jsonFileUptimeMetric) - - assertUpdateDatePresent(result) - assertLastValue("88326792", content) - assertName("System uptime", content) - assertStatus("NONE", result) - assertHistory("88326792", "1602331294376", content) - } - - private fun initZabbixWidgetWithMetric(metric: String = "") { - val config = initWidget().put(METRIC, metric) - zabbixWidgetTest = ZabbixWidget(vertx, config, initService()) - } - - private fun handleResponse(jsonFile: String): Pair { - val response = load("/com/cognifide/cogboard/widget/type/${widgetName()}/${jsonFile}") - zabbixWidgetTest.handleResponse(response) - return captureWhatIsSent(eventBus, captor) - } - - private fun assertLastValue(expected: String, content: JsonObject) { - Assertions.assertEquals(expected, content.getString("lastvalue")) - } - - private fun assertName(expected: String, content: JsonObject) { - Assertions.assertEquals(expected, content.getString("name")) - } - - private fun assertHistory(expected: String, key: String, content: JsonObject) { - Assertions.assertEquals(expected, content.getJsonObject("history").map[key]) - } - - companion object { - private const val METRIC = "selectedZabbixMetric" - private const val MAX_VALUE = "maxValue" - private const val RANGE = "range" - private const val cpuMetric = "system.cpu.util[,idle]" - private const val jsonFileCpuMetric = "cpu.json" - private const val discUsedMetric = "vfs.fs.size[/,used]" - private const val heapMetric = "jmx[\\\"java.lang:type=Memory\\\",\\\"HeapMemoryUsage.used\\\"]" - private const val jsonFileDiscUsedMetric = "disc_used.json" - private const val uptimeMetric = "system.uptime" - private const val jsonFileUptimeMetric = "uptime.json" - private const val jsonFileHeapMetric = "heap.json" - } +package com.cognifide.cogboard.widget.type + +import com.cognifide.cogboard.widget.type.zabbix.ZabbixWidget +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import com.cognifide.cogboard.TestHelper.Companion.readConfigFromResource as load + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ExtendWith(MockitoExtension::class) +class ZabbixWidgetTest : WidgetTestBase() { + + private lateinit var zabbixWidgetTest: ZabbixWidget + + override fun widgetName(): String = zabbixWidgetTest.javaClass.simpleName + + override fun initWidget(): JsonObject = super.initWidget() + .put(RANGE, JsonArray().add(20).add(60)) + .put(MAX_VALUE, 100) + + @BeforeEach + fun initTest() { + super.init() + } + + @Test + fun `Expect ok widget update message send on event bus`() { + initZabbixWidgetWithMetric(discUsedMetric) + val (result, content) = handleResponse(jsonFileDiscUsedMetric) + + assertUpdateDatePresent(result) + assertLastValue("7254701317", content) + assertName("Used disk space on \$1", content) + assertStatus("OK", result) + assertHistory("7254701317", "1602331216799", content) + } + + @Test + fun `Expect warn widget update message send on event bus`() { + initZabbixWidgetWithMetric(heapMetric) + val (result, content) = handleResponse(jsonFileHeapMetric) + + assertUpdateDatePresent(result) + assertLastValue("50744555432", content) + assertName("mem heap size", content) + assertStatus("UNSTABLE", result) + assertHistory("50744555432", "1602486782740", content) + } + + @Test + fun `Expect fail widget update message send on event bus`() { + initZabbixWidgetWithMetric(cpuMetric) + val (result, content) = handleResponse(jsonFileCpuMetric) + + assertUpdateDatePresent(result) + assertLastValue("63", content) + assertName("CPU \$2 time", content) + assertStatus("FAIL", result) + assertHistory("63", "1602331143732", content) + } + + @Test + fun `Expect unknown widget update message send on event bus`() { + initZabbixWidgetWithMetric(uptimeMetric) + val (result, content) = handleResponse(jsonFileUptimeMetric) + + assertUpdateDatePresent(result) + assertLastValue("88326792", content) + assertName("System uptime", content) + assertStatus("NONE", result) + assertHistory("88326792", "1602331294376", content) + } + + private fun initZabbixWidgetWithMetric(metric: String = "") { + val config = initWidget().put(METRIC, metric) + zabbixWidgetTest = ZabbixWidget(vertx, config, initService()) + } + + private fun handleResponse(jsonFile: String): Pair { + val response = load("/com/cognifide/cogboard/widget/type/${widgetName()}/${jsonFile}") + zabbixWidgetTest.handleResponse(response) + return captureWhatIsSent(eventBus, captor) + } + + private fun assertLastValue(expected: String, content: JsonObject) { + Assertions.assertEquals(expected, content.getString("lastvalue")) + } + + private fun assertName(expected: String, content: JsonObject) { + Assertions.assertEquals(expected, content.getString("name")) + } + + private fun assertHistory(expected: String, key: String, content: JsonObject) { + Assertions.assertEquals(expected, content.getJsonObject("history").map[key]) + } + + companion object { + private const val METRIC = "selectedZabbixMetric" + private const val MAX_VALUE = "maxValue" + private const val RANGE = "range" + private const val cpuMetric = "system.cpu.util[,idle]" + private const val jsonFileCpuMetric = "cpu.json" + private const val discUsedMetric = "vfs.fs.size[/,used]" + private const val heapMetric = "jmx[\\\"java.lang:type=Memory\\\",\\\"HeapMemoryUsage.used\\\"]" + private const val jsonFileDiscUsedMetric = "disc_used.json" + private const val uptimeMetric = "system.uptime" + private const val jsonFileUptimeMetric = "uptime.json" + private const val jsonFileHeapMetric = "heap.json" + } } \ No newline at end of file diff --git a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/DefaultLogParserStrategyTest.kt b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/DefaultLogParserStrategyTest.kt new file mode 100644 index 000000000..3cb1fca1c --- /dev/null +++ b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/logviewer/logparser/DefaultLogParserStrategyTest.kt @@ -0,0 +1,22 @@ +package com.cognifide.cogboard.widget.type.logviewer.logparser + +import org.junit.jupiter.api.Test +import java.lang.AssertionError + +class DefaultLogParserStrategyTest { + private val sampleLog = "2021-11-06:22:40:25 *DEBUG* [FelixStartLevel] Integer lobortis. bibendum Nulla mi" + private val parser = DefaultLogParserStrategy() + + @Test + fun parseSampleLog() { + assert(parser.variableFields == listOf("Provider", "Message")) + + val output = parser.parseLine(sampleLog) + + assert(output.type == "DEBUG") + assert(output.date == 1636238425L) + assert(output.variableData.size == 2) + assert(output.variableData[0].header == "FelixStartLevel") + assert(output.variableData[1].header == "Integer lobortis. bibendum Nulla mi") + } +} \ No newline at end of file diff --git a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/zabbix/ZabbixUtilTest.kt b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/zabbix/ZabbixUtilTest.kt index a5dfaeecb..ab914e7b3 100644 --- a/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/zabbix/ZabbixUtilTest.kt +++ b/cogboard-app/src/test/kotlin/com/cognifide/cogboard/widget/type/zabbix/ZabbixUtilTest.kt @@ -1,50 +1,50 @@ -package com.cognifide.cogboard.widget.type.zabbix - -import com.cognifide.cogboard.widget.Widget -import io.vertx.core.json.JsonArray -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -internal class ZabbixUtilTest { - - @Test - fun `Expect percentage value after conversion`() { - val percentage = SAMPLE_VALUE_IN_BYTES.convertToPercentage(MAX_VALUE_100_GB) - - assertEquals(51, percentage) - } - - @Test - fun `Expect OK status for value less than start of range value`() { - val status = status(59, range) - - assertEquals(Widget.Status.OK, status) - } - - @Test - fun `Expect UNSTABLE status for start of range value`() { - val status = status(60, range) - - assertEquals(Widget.Status.UNSTABLE, status) - } - - @Test - fun `Expect UNSTABLE status for value less than end of range value`() { - val status = status(79, range) - - assertEquals(Widget.Status.UNSTABLE, status) - } - - @Test - fun `Expect FAIL status for end of range value`() { - val status = status(80, range) - - assertEquals(Widget.Status.FAIL, status) - } - - companion object { - const val MAX_VALUE_100_GB = 100 - const val SAMPLE_VALUE_IN_BYTES = 50744555432L - val range: JsonArray = JsonArray().add(60).add(80) - } -} +package com.cognifide.cogboard.widget.type.zabbix + +import com.cognifide.cogboard.widget.Widget +import io.vertx.core.json.JsonArray +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ZabbixUtilTest { + + @Test + fun `Expect percentage value after conversion`() { + val percentage = SAMPLE_VALUE_IN_BYTES.convertToPercentage(MAX_VALUE_100_GB) + + assertEquals(51, percentage) + } + + @Test + fun `Expect OK status for value less than start of range value`() { + val status = status(59, range) + + assertEquals(Widget.Status.OK, status) + } + + @Test + fun `Expect UNSTABLE status for start of range value`() { + val status = status(60, range) + + assertEquals(Widget.Status.UNSTABLE, status) + } + + @Test + fun `Expect UNSTABLE status for value less than end of range value`() { + val status = status(79, range) + + assertEquals(Widget.Status.UNSTABLE, status) + } + + @Test + fun `Expect FAIL status for end of range value`() { + val status = status(80, range) + + assertEquals(Widget.Status.FAIL, status) + } + + companion object { + const val MAX_VALUE_100_GB = 100 + const val SAMPLE_VALUE_IN_BYTES = 50744555432L + val range: JsonArray = JsonArray().add(60).add(80) + } +} diff --git a/cogboard-app/src/test/resources/com/cognifide/cogboard/config/credentials-test.json b/cogboard-app/src/test/resources/com/cognifide/cogboard/config/credentials-test.json index 9cdcfe54e..e5fafd1ad 100644 --- a/cogboard-app/src/test/resources/com/cognifide/cogboard/config/credentials-test.json +++ b/cogboard-app/src/test/resources/com/cognifide/cogboard/config/credentials-test.json @@ -1,18 +1,22 @@ -{ - "credentials": [ - { - "id": "credentials1", - "label": "My Credentials 1", - "user": "user1", - "password": "password1", - "token": "token1" - }, - { - "id": "credentials2", - "label": "My Credentials 2", - "user": "user2", - "password": "password2", - "token": "token2" - } - ] -} +{ + "credentials": [ + { + "id": "credentials1", + "label": "My Credentials 1", + "user": "user1", + "password": "password1", + "token": "token1", + "sshKey": "key1", + "sshKeyPassphrase": "pass1" + }, + { + "id": "credentials2", + "label": "My Credentials 2", + "user": "user2", + "password": "password2", + "token": "token2", + "sshKey": "key2", + "sshKeyPassphrase": "pass2" + } + ] +} diff --git a/cogboard-app/src/test/resources/com/cognifide/cogboard/config/endpoints-test.json b/cogboard-app/src/test/resources/com/cognifide/cogboard/config/endpoints-test.json index f85a096c0..84cfe0013 100644 --- a/cogboard-app/src/test/resources/com/cognifide/cogboard/config/endpoints-test.json +++ b/cogboard-app/src/test/resources/com/cognifide/cogboard/config/endpoints-test.json @@ -1,17 +1,17 @@ -{ - "endpoints": [ - { - "id": "validEndpoint", - "label": "Valid Endpoint", - "url": "url", - "publicUrl": "Public Url", - "credentials": "credentials1" - }, - { - "id": "invalidEndpoint", - "label": "Invalid Endpoint", - "url": "url", - "credentials": "nonExistingCredentials" - } - ] -} +{ + "endpoints": [ + { + "id": "validEndpoint", + "label": "Valid Endpoint", + "url": "url", + "publicUrl": "Public Url", + "credentials": "credentials1" + }, + { + "id": "invalidEndpoint", + "label": "Invalid Endpoint", + "url": "url", + "credentials": "nonExistingCredentials" + } + ] +} diff --git a/cogboard-local-compose.yml b/cogboard-local-compose.yml index 327aeb6f9..c5efd52ef 100644 --- a/cogboard-local-compose.yml +++ b/cogboard-local-compose.yml @@ -5,6 +5,9 @@ networks: driver: overlay attachable: true +volumes: + mongo-data: + services: api-mocks: image: rodolpheche/wiremock @@ -16,16 +19,50 @@ services: - cognet command: ["--no-request-journal", "--global-response-templating"] + ssh-server: + image: ssh-server + hostname: ssh-server + networks: + - cognet + backend: image: "cogboard/cogboard-app:${COGBOARD_VERSION}" environment: - COGBOARD_VERSION=${COGBOARD_VERSION} + - MONGO_USERNAME=${MONGO_USER:-root} + - MONGO_PASSWORD=${MONGO_PASSWORD:-root} + - MONGO_HOST=mongo-logs-storage + - MONGO_PORT=27017 volumes: - "./mnt:/data" networks: - cognet - # ports: - # - "18092:18092" +# ports: +# - "18092:18092" + + mongo-logs-storage: + image: mongo:4 + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-root} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-root} + MONGO_INITDB_DATABASE: "logs" + volumes: + - "mongo-data:/data/db" + networks: + - cognet + + # mongo-logs-storage-viewer: + # image: mongo-express + # restart: always + # ports: + # - 8099:8081 + # environment: + # ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_USER:-root} + # ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD:-root} + # ME_CONFIG_MONGODB_URL: "mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo-logs-storage:27017/" + # networks: + # - cognet frontend: image: "cogboard/cogboard-web:${COGBOARD_VERSION}" diff --git a/cogboard-webapp/package-lock.json b/cogboard-webapp/package-lock.json index adebfe522..716308859 100644 --- a/cogboard-webapp/package-lock.json +++ b/cogboard-webapp/package-lock.json @@ -1194,6 +1194,19 @@ "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-10.1.0.tgz", "integrity": "sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==" }, + "@date-io/core": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz", + "integrity": "sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA==" + }, + "@date-io/moment": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-1.3.13.tgz", + "integrity": "sha512-3kJYusJtQuOIxq6byZlzAHoW/18iExJer9qfRF5DyyzdAk074seTuJfdofjz4RFfTd/Idk8WylOQpWtERqvFuQ==", + "requires": { + "@date-io/core": "^1.3.13" + } + }, "@emotion/cache": { "version": "10.0.29", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", @@ -1935,6 +1948,19 @@ "@babel/runtime": "^7.4.4" } }, + "@material-ui/pickers": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.10.tgz", + "integrity": "sha512-hS4pxwn1ZGXVkmgD4tpFpaumUaAg2ZzbTrxltfC5yPw4BJV+mGkfnQOB4VpWEYZw2jv65Z0wLwDE/piQiPPZ3w==", + "requires": { + "@babel/runtime": "^7.6.0", + "@date-io/core": "1.x", + "@types/styled-jsx": "^2.2.8", + "clsx": "^1.0.2", + "react-transition-group": "^4.0.0", + "rifm": "^0.7.0" + } + }, "@material-ui/styles": { "version": "4.11.4", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz", @@ -2487,6 +2513,14 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz", "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==" }, + "@types/styled-jsx": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.9.tgz", + "integrity": "sha512-W/iTlIkGEyTBGTEvZCey8EgQlQ5l0DwMqi3iOXlLs2kyBwYTXHKEiU6IZ5EwoRwngL8/dGYuzezSup89ttVHLw==", + "requires": { + "@types/react": "*" + } + }, "@types/tapable": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.7.tgz", @@ -2666,6 +2700,19 @@ "eslint-visitor-keys": "^2.0.0" } }, + "@virtuoso.dev/react-urx": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@virtuoso.dev/react-urx/-/react-urx-0.2.12.tgz", + "integrity": "sha512-Lcrrmq/UztM+rgepAdThdIk8dL3LEi9o2NTkL6ZLKPrTGjr5tSmsauD30/O8yu7Q0ncDnptmMR3OObdvMGuEKQ==", + "requires": { + "@virtuoso.dev/urx": "^0.2.12" + } + }, + "@virtuoso.dev/urx": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@virtuoso.dev/urx/-/urx-0.2.12.tgz", + "integrity": "sha512-Q9nlRqYb5Uq4Ynu8cWPSJ7LxpuZsI+MZ09IJlDAAgwuNgfWRArpVRP0VN0coYgUo2fKMjhmV69MTqaUbIBhu/g==" + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -13708,6 +13755,15 @@ "prop-types": "^15.6.2" } }, + "react-virtuoso": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-2.3.1.tgz", + "integrity": "sha512-Y5qsh5xaGvMAN7S2LOm0n5Hg5gl76GEFSNzD/LNSR6Bm8UFncmEwIKEqp7lJMyM9aLrFV67Ap4rA0JL63Eb/uw==", + "requires": { + "@virtuoso.dev/react-urx": "^0.2.12", + "@virtuoso.dev/urx": "^0.2.12" + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -14218,6 +14274,14 @@ "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=" }, + "rifm": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz", + "integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==", + "requires": { + "@babel/runtime": "^7.3.1" + } + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -16575,6 +16639,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "optional": true, "requires": { "arr-flatten": "^1.1.0", "array-unique": "^0.3.2", @@ -16592,6 +16657,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "optional": true, "requires": { "is-extendable": "^0.1.0" } @@ -16622,6 +16688,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "optional": true, "requires": { "extend-shallow": "^2.0.1", "is-number": "^3.0.0", @@ -16633,6 +16700,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "optional": true, "requires": { "is-extendable": "^0.1.0" } @@ -16682,12 +16750,14 @@ "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "optional": true }, "is-number": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "optional": true, "requires": { "kind-of": "^3.0.2" }, @@ -16696,6 +16766,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "optional": true, "requires": { "is-buffer": "^1.1.5" } @@ -16706,6 +16777,7 @@ "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "optional": true, "requires": { "arr-diff": "^4.0.0", "array-unique": "^0.3.2", @@ -16761,6 +16833,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "optional": true, "requires": { "is-number": "^3.0.0", "repeat-string": "^1.6.1" diff --git a/cogboard-webapp/package.json b/cogboard-webapp/package.json index a470304ac..b3bfa7a82 100644 --- a/cogboard-webapp/package.json +++ b/cogboard-webapp/package.json @@ -3,10 +3,12 @@ "version": "0.1.0", "private": true, "dependencies": { + "@date-io/moment": "^1.3.13", "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", "@material-ui/core": "^4.9.11", "@material-ui/icons": "^4.9.1", + "@material-ui/pickers": "^3.3.10", "@reach/router": "^1.3.3", "chartist": "^0.11.4", "copy-to-clipboard": "^3.3.1", @@ -25,6 +27,7 @@ "react-iframe": "latest", "react-redux": "^7.2.0", "react-scripts": "^4.0.3", + "react-virtuoso": "^2.3.1", "redux": "^4.0.5", "redux-thunk": "^2.3.0", "reselect": "^4.0.0", @@ -70,5 +73,10 @@ "pretty-quick --staged", "git add" ] + }, + "jest": { + "transformIgnorePatterns": [ + "node_modules/(?!@ngrx|(?!deck.gl)|ng-dynamic)" + ] } } diff --git a/cogboard-webapp/src/App.test.js b/cogboard-webapp/src/App.test.js index a754b201b..a19217900 100644 --- a/cogboard-webapp/src/App.test.js +++ b/cogboard-webapp/src/App.test.js @@ -1,9 +1,18 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import configureStore from './configureStore'; import App from './App'; +const store = configureStore(); + it('renders without crashing', () => { const div = document.createElement('div'); - ReactDOM.render(, div); + ReactDOM.render( + + + , + div + ); ReactDOM.unmountComponentAtNode(div); }); diff --git a/cogboard-webapp/src/actions/actionCreators.js b/cogboard-webapp/src/actions/actionCreators.js index 9d0cfcc88..a24e45ad9 100644 --- a/cogboard-webapp/src/actions/actionCreators.js +++ b/cogboard-webapp/src/actions/actionCreators.js @@ -30,7 +30,8 @@ import { ADD_SETTINGS_ITEM, EDIT_SETTINGS_ITEM, DELETE_SETTINGS_ITEM, - WAITING_FOR_NEW_VERSION_DATA + WAITING_FOR_NEW_VERSION_DATA, + TOGGLE_LOGS_VIEWER_LOG } from './types'; import { INITIAL_BOARD_PROPS } from '../constants'; @@ -193,3 +194,8 @@ export const waitingForNewVersion = data => ({ type: WAITING_FOR_NEW_VERSION_DATA, payload: data }); + +export const toggleLogsViewerLog = ({ wid, logid }) => ({ + type: TOGGLE_LOGS_VIEWER_LOG, + payload: { wid, logid } +}); diff --git a/cogboard-webapp/src/actions/types.js b/cogboard-webapp/src/actions/types.js index 93823f81d..fe3445f82 100644 --- a/cogboard-webapp/src/actions/types.js +++ b/cogboard-webapp/src/actions/types.js @@ -30,3 +30,4 @@ export const ADD_SETTINGS_ITEM = 'ADD_SETTINGS_ITEM'; export const EDIT_SETTINGS_ITEM = 'EDIT_SETTINGS_ITEM'; export const DELETE_SETTINGS_ITEM = 'DELETE_SETTINGS_ITEM'; export const WAITING_FOR_NEW_VERSION_DATA = 'WAITING_FOR_NEW_VERSION_DATA'; +export const TOGGLE_LOGS_VIEWER_LOG = 'TOGGLE_LOGS_VIEWER_LOG'; diff --git a/cogboard-webapp/src/components/AddItem.js b/cogboard-webapp/src/components/AddItem.js index 24c38056e..6d338e0bb 100644 --- a/cogboard-webapp/src/components/AddItem.js +++ b/cogboard-webapp/src/components/AddItem.js @@ -1,12 +1,25 @@ -import React, { cloneElement } from 'react'; +import React, { cloneElement, useEffect } from 'react'; import { useToggle } from '../hooks'; import AddButton from './AddButton'; import AppDialog from './AppDialog'; -const AddItem = ({ itemName, largeButton, submitAction, children }) => { +const AddItem = ({ + itemName, + largeButton, + submitAction, + children, + shouldOpen +}) => { const [dialogOpened, openDialog, handleDialogClose] = useToggle(); + useEffect(() => { + if (shouldOpen) { + openDialog(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldOpen]); + const handleAddItemClick = event => { event.stopPropagation(); openDialog(); diff --git a/cogboard-webapp/src/components/AppDialog.js b/cogboard-webapp/src/components/AppDialog.js index 5c97706a6..4d6517353 100644 --- a/cogboard-webapp/src/components/AppDialog.js +++ b/cogboard-webapp/src/components/AppDialog.js @@ -9,7 +9,7 @@ const StyledDialog = styled(props => ( ))` .paper { - width: 500px; + width: 700px; max-width: calc(100vw - 30px); padding: 15px; } diff --git a/cogboard-webapp/src/components/CredentialForm/index.js b/cogboard-webapp/src/components/CredentialForm/index.js index 8cc72821a..bd8757fb6 100644 --- a/cogboard-webapp/src/components/CredentialForm/index.js +++ b/cogboard-webapp/src/components/CredentialForm/index.js @@ -22,7 +22,9 @@ const CredentialsForm = ({ 'UsernameField', 'PasswordField', 'PasswordConfirmationField', - 'TokenField' + 'TokenField', + 'SSHKeyField', + 'SSHKeyPassphraseField' ]; const constraints = { @@ -82,7 +84,9 @@ CredentialsForm.defaultProps = { user: '', password: '', confirmationPassword: '', - token: '' + token: '', + sshKey: '', + sshKeyPassphrase: '' }; export default CredentialsForm; diff --git a/cogboard-webapp/src/components/DeleteItem.js b/cogboard-webapp/src/components/DeleteItem.js index 4cd91ccab..30024619f 100644 --- a/cogboard-webapp/src/components/DeleteItem.js +++ b/cogboard-webapp/src/components/DeleteItem.js @@ -1,5 +1,4 @@ import React from 'react'; -import { useDispatch } from 'react-redux'; import { useToggle } from '../hooks'; @@ -8,11 +7,10 @@ import { Delete } from '@material-ui/icons'; import ConfirmationDialog from './ConfirmationDialog'; const DeleteItem = ({ id, label, itemName, deleteAction }) => { - const dispatch = useDispatch(); const [dialogOpened, openDialog, handleDialogClose] = useToggle(); const handleDelete = id => () => { - dispatch(deleteAction(id)); + deleteAction(id); handleDialogClose(); }; diff --git a/cogboard-webapp/src/components/FormField/index.js b/cogboard-webapp/src/components/FormField/index.js index 5ecdd2007..30730b9fe 100644 --- a/cogboard-webapp/src/components/FormField/index.js +++ b/cogboard-webapp/src/components/FormField/index.js @@ -1,31 +1,31 @@ -import React from 'react'; - -import { camelToKebab, createValueRef } from './helpers'; -import dialogFields from '../widgets/dialogFields'; - -const FormField = ({ field, values, handleChange, errors, rootName }) => { - const { - component: DialogField, - name, - initialValue = '', - valueUpdater, - validator, - ...dialogFieldProps - } = dialogFields[field]; - - const valueRef = createValueRef(values, initialValue, name); - - return ( - - ); -}; - -export default FormField; +import React from 'react'; + +import { camelToKebab, createValueRef } from './helpers'; +import dialogFields from '../widgets/dialogFields'; + +const FormField = ({ field, values, handleChange, errors, rootName }) => { + const { + component: DialogField, + name, + initialValue = '', + valueUpdater, + validator, + ...dialogFieldProps + } = dialogFields[field]; + + const valueRef = createValueRef(values, initialValue, name); + + return ( + + ); +}; + +export default FormField; diff --git a/cogboard-webapp/src/components/SemiProgressBar/index.js b/cogboard-webapp/src/components/SemiProgressBar/index.js index 8a14ee271..2045c260e 100644 --- a/cogboard-webapp/src/components/SemiProgressBar/index.js +++ b/cogboard-webapp/src/components/SemiProgressBar/index.js @@ -1,93 +1,90 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { StyledSemiCircleContainer, StyledPercentageText } from './styled'; - -const SemiCircleProgress = ({ - stroke, - strokeWidth, - background, - diameter, - showPercentValue, - percentage, - text -}) => { - const coordinateForCircle = diameter / 2; - const radius = (diameter - 2 * strokeWidth) / 2; - const circumference = Math.PI * radius; - - const setPercentageValue = () => { - let percentageValue; - - if (percentage > 100) { - percentageValue = 100; - } else if (percentage < 0) { - percentageValue = 0; - } else { - percentageValue = percentage; - } - - return percentageValue; - } - - const semiCirclePercentage = setPercentageValue() * (circumference / 100); - - return ( - - - - - - {showPercentValue && ( - - {text ? `${text}GB/(${percentage}%)` : `${percentage}%`} - - )} - - ); -}; - -SemiCircleProgress.propTypes = { - strokeWidth: PropTypes.number, - diameter: PropTypes.number, - showPercentValue: PropTypes.bool, - stroke: PropTypes.string, - background: PropTypes.string, - text: PropTypes.number -}; - -SemiCircleProgress.defaultProps = { - strokeWidth: 10, - diameter: 200, - showPercentValue: false, - stroke: "#02B732", - background: "#D0D0CE", -}; - -export default SemiCircleProgress; +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyledSemiCircleContainer, StyledPercentageText } from './styled'; + +const SemiCircleProgress = ({ + stroke, + strokeWidth, + background, + diameter, + showPercentValue, + percentage, + text +}) => { + const coordinateForCircle = diameter / 2; + const radius = (diameter - 2 * strokeWidth) / 2; + const circumference = Math.PI * radius; + + const setPercentageValue = () => { + let percentageValue; + + if (percentage > 100) { + percentageValue = 100; + } else if (percentage < 0) { + percentageValue = 0; + } else { + percentageValue = percentage; + } + + return percentageValue; + }; + + const semiCirclePercentage = setPercentageValue() * (circumference / 100); + + return ( + + + + + + {showPercentValue && ( + + {text ? `${text}GB/(${percentage}%)` : `${percentage}%`} + + )} + + ); +}; + +SemiCircleProgress.propTypes = { + strokeWidth: PropTypes.number, + diameter: PropTypes.number, + showPercentValue: PropTypes.bool, + stroke: PropTypes.string, + background: PropTypes.string, + text: PropTypes.number +}; + +SemiCircleProgress.defaultProps = { + strokeWidth: 10, + diameter: 200, + showPercentValue: false, + stroke: '#02B732', + background: '#D0D0CE' +}; + +export default SemiCircleProgress; diff --git a/cogboard-webapp/src/components/SemiProgressBar/styled.js b/cogboard-webapp/src/components/SemiProgressBar/styled.js index d05a4245c..e6d3564e6 100644 --- a/cogboard-webapp/src/components/SemiProgressBar/styled.js +++ b/cogboard-webapp/src/components/SemiProgressBar/styled.js @@ -1,21 +1,21 @@ -import styled from '@emotion/styled/macro'; - -export const StyledPercentageText = styled.span` - bottom: 0; - left: 0; - position: absolute; - text-align: center; - width: 100%; -`; - -export const StyledSemiCircleContainer = styled.div` - display: flex; - justify-content: center; - position: relative; - margin-bottom: 8px; - - svg { - overflow: hidden; - transform: rotateY(180deg); - } -`; +import styled from '@emotion/styled/macro'; + +export const StyledPercentageText = styled.span` + bottom: 0; + left: 0; + position: absolute; + text-align: center; + width: 100%; +`; + +export const StyledSemiCircleContainer = styled.div` + display: flex; + justify-content: center; + position: relative; + margin-bottom: 8px; + + svg { + overflow: hidden; + transform: rotateY(180deg); + } +`; diff --git a/cogboard-webapp/src/components/SettingsMenu/index.js b/cogboard-webapp/src/components/SettingsMenu/index.js index 0752f24e3..293a5a040 100644 --- a/cogboard-webapp/src/components/SettingsMenu/index.js +++ b/cogboard-webapp/src/components/SettingsMenu/index.js @@ -84,7 +84,7 @@ const SettingsMenu = ({ className }) => { id={id} label={label} itemName={name} - deleteAction={deleteAction} + deleteAction={id => dispatch(deleteAction(id))} /> diff --git a/cogboard-webapp/src/components/ZabbixChart/helpers.js b/cogboard-webapp/src/components/ZabbixChart/helpers.js index 5c0b24bce..a3ef0cc70 100644 --- a/cogboard-webapp/src/components/ZabbixChart/helpers.js +++ b/cogboard-webapp/src/components/ZabbixChart/helpers.js @@ -1,8 +1,10 @@ -export const calculatePercentageValue = (value, maxValue) => Math.round((100 * value) / maxValue) - -export const getNumberOfElements = (array, number) => array.slice(Math.max(array.length - number, 0)); - -export const convertEpochToDate = (value) => { - const convertedEpoch = parseInt(value) + (new Date().getTimezoneOffset() * -1); - return new Date(convertedEpoch); -} \ No newline at end of file +export const calculatePercentageValue = (value, maxValue) => + Math.round((100 * value) / maxValue); + +export const getNumberOfElements = (array, number) => + array.slice(Math.max(array.length - number, 0)); + +export const convertEpochToDate = value => { + const convertedEpoch = parseInt(value) + new Date().getTimezoneOffset() * -1; + return new Date(convertedEpoch); +}; diff --git a/cogboard-webapp/src/components/ZabbixChart/index.js b/cogboard-webapp/src/components/ZabbixChart/index.js index a5d14bf67..5796551c1 100644 --- a/cogboard-webapp/src/components/ZabbixChart/index.js +++ b/cogboard-webapp/src/components/ZabbixChart/index.js @@ -1,134 +1,156 @@ -import React, { useMemo, useState } from 'react'; -import { shallowEqual, useSelector } from 'react-redux'; -import Chartist from 'chartist'; -import ChartistGraph from 'react-chartist'; -import { StyledZabbixChart } from './styled'; -import { getNumberOfElements, calculatePercentageValue, convertEpochToDate } from './helpers'; -import { - COLORS, - ZABBIX_METRICS_WITH_MAX_VALUE, - ZABBIX_METRICS_WITH_PROGRESS -} from '../../constants'; - -const zabbixChartConfig = { - column1: { - numberOfResults: 6, - }, - column2: { - numberOfResults: 15, - }, - column3: { - numberOfResults: 23, - }, - other: { - numberOfResults: 40, - } -} - -const ZabbixChart = ({ id, content }) => { - const [data, setData] = useState({}) - const widgetData = useSelector( - ({ widgets }) => widgets.widgetsById[id], - shallowEqual - ); - const widgetZabbixMetric = widgetData.selectedZabbixMetric; - const widgetConfig = widgetData.config; - const range = widgetData.range || []; - const maxValue = widgetData.maxValue || 0; - const setProgressSize = zabbixChartConfig[`column${widgetConfig.columns}`] - ? zabbixChartConfig[`column${widgetConfig.columns}`] - : zabbixChartConfig.other; - const checkMetricHasMaxValue = ZABBIX_METRICS_WITH_MAX_VALUE.includes(widgetZabbixMetric); - const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes(widgetZabbixMetric); - const options = { - chartPadding: 0, - width: `95%`, - height: '100%' - }; - - useMemo(() => { - const LABELS = Object.keys(content.history).map((label) => convertEpochToDate(label).toLocaleString()); - let SERIES = Object.values(content.history); - - if (checkMetricHasMaxValue) { - SERIES = SERIES.map((serie) => { - return Math.round(serie / Math.pow(10, 9)); - }); - } - - setData({ - labels: getNumberOfElements(LABELS, setProgressSize.numberOfResults), - series: [ - getNumberOfElements(SERIES, setProgressSize.numberOfResults) - ] - }); - }, [content.history, setProgressSize, checkMetricHasMaxValue]); - - const setAxisYTitle = () => { - let titleText; - - if (checkMetricHasMaxValue) { - titleText = '[GB]'; - } else if (!checkMetricHasProgress) { - titleText = 'No.' - } else { - titleText = '[%]' - } - - return titleText; - } - - const setBarColor = (value, maxValue, range) => { - if (!value) return `${COLORS.WHITE}`; - - const percentageValue = checkMetricHasMaxValue ? calculatePercentageValue(value.y, maxValue) : value.y; - let barColorStatus; - - if (percentageValue > range[1]) { - barColorStatus = `${COLORS.RED}`; - } else if (percentageValue < range[0]) { - barColorStatus = `${COLORS.GREEN_DEFAULT}`; - } else { - barColorStatus = `${COLORS.ORANGE}`; - } - - return barColorStatus; - } - - const onDrawHandler = (context) => { - const barColor = setBarColor(context.value, maxValue, range); - - if (context.type === 'label' && context.axis.units.pos === 'x') { - const textHtml = ['
'].join(''); - const multilineText = Chartist.Svg('svg').foreignObject(textHtml, { x: context.x, y: context.y, width: context.axis.stepLength, height: 20 }, 'ct-label ct-horizontal cta-end custom-label', true); - context.element.replace(multilineText); - } - - if (context.type === 'bar' && checkMetricHasProgress) { - context.element.attr({ - style: `stroke: ${barColor};` - }); - } else { - context.element.attr({ - style: `stroke: ${COLORS.WHITE};` - }); - } - } - - return ( - - onDrawHandler(e) - }} - data={data} - options={options} - type='Bar' - /> - - ); -}; - -export default ZabbixChart; +import React, { useMemo, useState } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import Chartist from 'chartist'; +import ChartistGraph from 'react-chartist'; +import { StyledZabbixChart } from './styled'; +import { + getNumberOfElements, + calculatePercentageValue, + convertEpochToDate +} from './helpers'; +import { + COLORS, + ZABBIX_METRICS_WITH_MAX_VALUE, + ZABBIX_METRICS_WITH_PROGRESS +} from '../../constants'; + +const zabbixChartConfig = { + column1: { + numberOfResults: 6 + }, + column2: { + numberOfResults: 15 + }, + column3: { + numberOfResults: 23 + }, + other: { + numberOfResults: 40 + } +}; + +const ZabbixChart = ({ id, content }) => { + const [data, setData] = useState({}); + const widgetData = useSelector( + ({ widgets }) => widgets.widgetsById[id], + shallowEqual + ); + const widgetZabbixMetric = widgetData.selectedZabbixMetric; + const widgetConfig = widgetData.config; + const range = widgetData.range || []; + const maxValue = widgetData.maxValue || 0; + const setProgressSize = zabbixChartConfig[`column${widgetConfig.columns}`] + ? zabbixChartConfig[`column${widgetConfig.columns}`] + : zabbixChartConfig.other; + const checkMetricHasMaxValue = ZABBIX_METRICS_WITH_MAX_VALUE.includes( + widgetZabbixMetric + ); + const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes( + widgetZabbixMetric + ); + const options = { + chartPadding: 0, + width: `95%`, + height: '100%' + }; + + useMemo(() => { + const LABELS = Object.keys(content.history).map(label => + convertEpochToDate(label).toLocaleString() + ); + let SERIES = Object.values(content.history); + + if (checkMetricHasMaxValue) { + SERIES = SERIES.map(serie => { + return Math.round(serie / Math.pow(10, 9)); + }); + } + + setData({ + labels: getNumberOfElements(LABELS, setProgressSize.numberOfResults), + series: [getNumberOfElements(SERIES, setProgressSize.numberOfResults)] + }); + }, [content.history, setProgressSize, checkMetricHasMaxValue]); + + const setAxisYTitle = () => { + let titleText; + + if (checkMetricHasMaxValue) { + titleText = '[GB]'; + } else if (!checkMetricHasProgress) { + titleText = 'No.'; + } else { + titleText = '[%]'; + } + + return titleText; + }; + + const setBarColor = (value, maxValue, range) => { + if (!value) return `${COLORS.WHITE}`; + + const percentageValue = checkMetricHasMaxValue + ? calculatePercentageValue(value.y, maxValue) + : value.y; + let barColorStatus; + + if (percentageValue > range[1]) { + barColorStatus = `${COLORS.RED}`; + } else if (percentageValue < range[0]) { + barColorStatus = `${COLORS.GREEN_DEFAULT}`; + } else { + barColorStatus = `${COLORS.ORANGE}`; + } + + return barColorStatus; + }; + + const onDrawHandler = context => { + const barColor = setBarColor(context.value, maxValue, range); + + if (context.type === 'label' && context.axis.units.pos === 'x') { + const textHtml = [ + '
' + ].join(''); + const multilineText = Chartist.Svg('svg').foreignObject( + textHtml, + { + x: context.x, + y: context.y, + width: context.axis.stepLength, + height: 20 + }, + 'ct-label ct-horizontal cta-end custom-label', + true + ); + context.element.replace(multilineText); + } + + if (context.type === 'bar' && checkMetricHasProgress) { + context.element.attr({ + style: `stroke: ${barColor};` + }); + } else { + context.element.attr({ + style: `stroke: ${COLORS.WHITE};` + }); + } + }; + + return ( + + onDrawHandler(e) + }} + data={data} + options={options} + type="Bar" + /> + + ); +}; + +export default ZabbixChart; diff --git a/cogboard-webapp/src/components/ZabbixChart/styled.js b/cogboard-webapp/src/components/ZabbixChart/styled.js index 2b824d26c..525bd4441 100644 --- a/cogboard-webapp/src/components/ZabbixChart/styled.js +++ b/cogboard-webapp/src/components/ZabbixChart/styled.js @@ -1,70 +1,70 @@ -import styled from '@emotion/styled/macro'; - -import { COLORS } from '../../constants'; - -export const StyledZabbixChart = styled.div` - position: relative; - - &::before, - &::after { - color: ${COLORS.WHITE}; - font-size: 11px; - position: absolute; - text-align: center; - } - - &::before { - content: attr(data-axis-x); - width: 100%; - left: 0; - bottom: 2px; - } - - &::after { - content: attr(data-axis-y); - left: -18px; - top: 36%; - transform: rotate(-90deg) translateY(50%); - } - - svg { - overflow: visible; - } - - .ct-label { - color: ${COLORS.WHITE}; - } - - .custom-label { - overflow: visible; - text-align: center; - - .tooltip { - background-color: white; - border-radius: 50%; - display: inline-block; - height: 6px; - margin: 0 auto; - position: relative; - width: 6px; - - &::after { - background-color: rgba(97, 97, 97, 0.9); - color: ${COLORS.WHITE}; - content: attr(data-tooltip); - left: -32px; - opacity: 0; - padding: 4px 8px; - position: absolute; - text-align: center; - top: 16px; - visibility: hidden; - } - } - - &:hover .tooltip::after { - opacity: 1; - visibility: visible; - } - } -`; +import styled from '@emotion/styled/macro'; + +import { COLORS } from '../../constants'; + +export const StyledZabbixChart = styled.div` + position: relative; + + &::before, + &::after { + color: ${COLORS.WHITE}; + font-size: 11px; + position: absolute; + text-align: center; + } + + &::before { + content: attr(data-axis-x); + width: 100%; + left: 0; + bottom: 2px; + } + + &::after { + content: attr(data-axis-y); + left: -18px; + top: 36%; + transform: rotate(-90deg) translateY(50%); + } + + svg { + overflow: visible; + } + + .ct-label { + color: ${COLORS.WHITE}; + } + + .custom-label { + overflow: visible; + text-align: center; + + .tooltip { + background-color: white; + border-radius: 50%; + display: inline-block; + height: 6px; + margin: 0 auto; + position: relative; + width: 6px; + + &::after { + background-color: rgba(97, 97, 97, 0.9); + color: ${COLORS.WHITE}; + content: attr(data-tooltip); + left: -32px; + opacity: 0; + padding: 4px 8px; + position: absolute; + text-align: center; + top: 16px; + visibility: hidden; + } + } + + &:hover .tooltip::after { + opacity: 1; + visibility: visible; + } + } +`; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/FileTextInput.js b/cogboard-webapp/src/components/widgets/dialogFields/FileTextInput.js new file mode 100644 index 000000000..c38a611fb --- /dev/null +++ b/cogboard-webapp/src/components/widgets/dialogFields/FileTextInput.js @@ -0,0 +1,70 @@ +import React, { useState, useRef } from 'react'; + +import { Button, IconButton, Tooltip } from '@material-ui/core'; +import { Delete } from '@material-ui/icons'; +import { StyledValidationMessages } from '../../WidgetForm/styled'; +import { + StyledHorizontalStack, + StyledLabel, + StyledVerticalStack +} from './styled'; + +const FileTextInput = ({ error, dataCy, onChange }) => { + const [filename, setFilename] = useState(''); + const inputRef = useRef(null); + + const getFileContents = async event => { + event.preventDefault(); + const file = event.target.files[0]; + + if (file) { + const reader = new FileReader(); + reader.onload = async event => { + const text = event.target.result; + event.target.value = text; + onChange(event); + }; + reader.readAsText(file); + setFilename(file.name); + } + }; + + const deleteFile = event => { + event.preventDefault(); + inputRef.current.value = null; + setFilename(''); + event.target.value = ''; + onChange(event); + }; + + return ( + + SSH private key + + + {filename && ( + + deleteFile(e)}> + + + + )} + + + + ); +}; + +export default FileTextInput; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/LinkListInput.js b/cogboard-webapp/src/components/widgets/dialogFields/LinkListInput.js index 65f806730..8c5d44475 100644 --- a/cogboard-webapp/src/components/widgets/dialogFields/LinkListInput.js +++ b/cogboard-webapp/src/components/widgets/dialogFields/LinkListInput.js @@ -1,177 +1,177 @@ -import React, { useState } from 'react'; -import { remove } from 'ramda'; -import { v4 } from 'uuid'; -import { prepareChangeEvent, RenderDragableList } from './helpers'; -import { hasError } from '../../../utils/components'; -import { Add, Check, Error } from '@material-ui/icons'; -import { StyledFab, StyledInput, StyledFormControl } from './styled'; -import { StyledFormHelperText } from '../../styled'; - -const LinkListInput = ({ value, onChange }) => { - const [formValueTitle, setFormValueTitle] = useState(''); - const [formValueUrl, setFormValueUrl] = useState(''); - const [formError, setFormError] = useState(); - const [urlError, setUrlError] = useState(false); - const [editMode, setEditMode] = useState(false); - const handleChangeTitle = event => setFormValueTitle(event.target.value); - const handleChangeUrl = event => { - if (!event.target.value.match(/^(http|https|ws|ftp):\/\/.*([:.]).*/)) { - setUrlError(true); - } else { - setUrlError(false); - } - - setFormValueUrl(event.target.value); - }; - - const [linkList, setLinkList] = useState(() => - (value || []).map(linkItem => { - return { - id: linkItem.id, - linkTitle: linkItem.linkTitle, - linkUrl: linkItem.linkUrl - }; - }) - ); - - const resetInput = () => { - setFormValueTitle(''); - setFormValueUrl(''); - }; - - const onSaveClick = () => { - handleSave({ - linkTitle: formValueTitle, - linkUrl: formValueUrl - }); - }; - - const handleSave = linkItem => { - let updatedItems; - - if (urlError) { - return; - } else if ( - linkItem.linkUrl.length === 0 || - linkItem.linkTitle.length === 0 - ) { - setFormError('Fill Title and Url field'); - return; - } else { - setFormError(undefined); - } - - if (editMode) { - updatedItems = linkList; - const updatedItemId = linkList.findIndex(el => el.id === editMode); - updatedItems[updatedItemId] = { - id: updatedItems[updatedItemId].id, - linkTitle: linkItem.linkTitle, - linkUrl: linkItem.linkUrl - }; - setEditMode(false); - } else { - updatedItems = [ - ...linkList, - { id: v4(), linkTitle: linkItem.linkTitle, linkUrl: linkItem.linkUrl } - ]; - } - - setLinkList(updatedItems); - onChange(prepareChangeEvent(updatedItems, 'array')); - resetInput(); - }; - - const handleDelete = itemIndex => { - const itemList = remove(itemIndex, 1, linkList); - setLinkList(itemList); - onChange(prepareChangeEvent(itemList, 'array')); - }; - - const handleKeyPressed = event => { - if (event.key === 'Enter') { - event.preventDefault(); - - if (!formValueUrl) { - return; - } - - handleSave({ - id: v4(), - linkUrl: formValueUrl, - linkTitle: formValueTitle - }); - } - - return; - }; - - const handleEdit = id => { - const editItem = linkList.find(el => el.id === id); - setFormValueTitle(editItem.linkTitle); - setFormValueUrl(editItem.linkUrl); - setEditMode(editItem.id); - }; - - return ( - - {formError && ( - - - {formError} - - )} - - - {urlError && ( - - - Invalid Url - - )} - - {editMode ? ( - <> - Save Link - - ) : ( - <> - Add Link - - )} - - - - ); -}; - -export default LinkListInput; +import React, { useState } from 'react'; +import { remove } from 'ramda'; +import { v4 } from 'uuid'; +import { prepareChangeEvent, RenderDragableList } from './helpers'; +import { hasError } from '../../../utils/components'; +import { Add, Check, Error } from '@material-ui/icons'; +import { StyledFab, StyledInput, StyledFormControl } from './styled'; +import { StyledFormHelperText } from '../../styled'; + +const LinkListInput = ({ value, onChange }) => { + const [formValueTitle, setFormValueTitle] = useState(''); + const [formValueUrl, setFormValueUrl] = useState(''); + const [formError, setFormError] = useState(); + const [urlError, setUrlError] = useState(false); + const [editMode, setEditMode] = useState(false); + const handleChangeTitle = event => setFormValueTitle(event.target.value); + const handleChangeUrl = event => { + if (!event.target.value.match(/^(http|https|ws|ftp):\/\/.*([:.]).*/)) { + setUrlError(true); + } else { + setUrlError(false); + } + + setFormValueUrl(event.target.value); + }; + + const [linkList, setLinkList] = useState(() => + (value || []).map(linkItem => { + return { + id: linkItem.id, + linkTitle: linkItem.linkTitle, + linkUrl: linkItem.linkUrl + }; + }) + ); + + const resetInput = () => { + setFormValueTitle(''); + setFormValueUrl(''); + }; + + const onSaveClick = () => { + handleSave({ + linkTitle: formValueTitle, + linkUrl: formValueUrl + }); + }; + + const handleSave = linkItem => { + let updatedItems; + + if (urlError) { + return; + } else if ( + linkItem.linkUrl.length === 0 || + linkItem.linkTitle.length === 0 + ) { + setFormError('Fill Title and Url field'); + return; + } else { + setFormError(undefined); + } + + if (editMode) { + updatedItems = linkList; + const updatedItemId = linkList.findIndex(el => el.id === editMode); + updatedItems[updatedItemId] = { + id: updatedItems[updatedItemId].id, + linkTitle: linkItem.linkTitle, + linkUrl: linkItem.linkUrl + }; + setEditMode(false); + } else { + updatedItems = [ + ...linkList, + { id: v4(), linkTitle: linkItem.linkTitle, linkUrl: linkItem.linkUrl } + ]; + } + + setLinkList(updatedItems); + onChange(prepareChangeEvent(updatedItems, 'array')); + resetInput(); + }; + + const handleDelete = itemIndex => { + const itemList = remove(itemIndex, 1, linkList); + setLinkList(itemList); + onChange(prepareChangeEvent(itemList, 'array')); + }; + + const handleKeyPressed = event => { + if (event.key === 'Enter') { + event.preventDefault(); + + if (!formValueUrl) { + return; + } + + handleSave({ + id: v4(), + linkUrl: formValueUrl, + linkTitle: formValueTitle + }); + } + + return; + }; + + const handleEdit = id => { + const editItem = linkList.find(el => el.id === id); + setFormValueTitle(editItem.linkTitle); + setFormValueUrl(editItem.linkUrl); + setEditMode(editItem.id); + }; + + return ( + + {formError && ( + + + {formError} + + )} + + + {urlError && ( + + + Invalid Url + + )} + + {editMode ? ( + <> + Save Link + + ) : ( + <> + Add Link + + )} + + + + ); +}; + +export default LinkListInput; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/MaxValueInput.js b/cogboard-webapp/src/components/widgets/dialogFields/MaxValueInput.js index f933f896d..a70ef778c 100644 --- a/cogboard-webapp/src/components/widgets/dialogFields/MaxValueInput.js +++ b/cogboard-webapp/src/components/widgets/dialogFields/MaxValueInput.js @@ -1,38 +1,54 @@ -import React from 'react'; -import { TextField } from '@material-ui/core'; -import { ZABBIX_METRICS_WITH_PROGRESS, ZABBIX_METRICS_WITH_MAX_VALUE } from '../../../constants'; -import { prepareChangeEvent } from './helpers'; - -const MaxValueInput = ({ error, values, label, dataCy, onChange, ...other }) => { - const selectedMetric = values.selectedZabbixMetric; - - const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes(selectedMetric); - const checkMetricHasMaxValue = ZABBIX_METRICS_WITH_MAX_VALUE.includes(selectedMetric); - - const handleChange = (evt) => { - const formattedValue = evt.target.value ? parseFloat(evt.target.value.replace(/,/g, '')) : '0'; - onChange(prepareChangeEvent(parseInt(formattedValue), 'number')); - } - - return ( - <> - {(checkMetricHasProgress && checkMetricHasMaxValue) && ( - - )} - - ); -}; - -export default MaxValueInput; +import React from 'react'; +import { TextField } from '@material-ui/core'; +import { + ZABBIX_METRICS_WITH_PROGRESS, + ZABBIX_METRICS_WITH_MAX_VALUE +} from '../../../constants'; +import { prepareChangeEvent } from './helpers'; + +const MaxValueInput = ({ + error, + values, + label, + dataCy, + onChange, + ...other +}) => { + const selectedMetric = values.selectedZabbixMetric; + + const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes( + selectedMetric + ); + const checkMetricHasMaxValue = ZABBIX_METRICS_WITH_MAX_VALUE.includes( + selectedMetric + ); + + const handleChange = evt => { + const formattedValue = evt.target.value + ? parseFloat(evt.target.value.replace(/,/g, '')) + : '0'; + onChange(prepareChangeEvent(parseInt(formattedValue), 'number')); + }; + + return ( + <> + {checkMetricHasProgress && checkMetricHasMaxValue && ( + + )} + + ); +}; + +export default MaxValueInput; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/ParserTypeInput.js b/cogboard-webapp/src/components/widgets/dialogFields/ParserTypeInput.js new file mode 100644 index 000000000..6e625c46e --- /dev/null +++ b/cogboard-webapp/src/components/widgets/dialogFields/ParserTypeInput.js @@ -0,0 +1,21 @@ +import React from 'react'; +import DropdownField from '../../DropdownField'; +import { MenuItem } from '@material-ui/core'; + +const ParserTypeInput = props => { + const parsers = [{ id: 'default', label: 'Default' }]; + + return ( + + {parsers => + parsers.map(({ id, label }) => ( + + {label} + + )) + } + + ); +}; + +export default ParserTypeInput; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/RangeSlider.js b/cogboard-webapp/src/components/widgets/dialogFields/RangeSlider.js index 6b7d43426..5fc0a32a4 100644 --- a/cogboard-webapp/src/components/widgets/dialogFields/RangeSlider.js +++ b/cogboard-webapp/src/components/widgets/dialogFields/RangeSlider.js @@ -1,52 +1,54 @@ -import React, { useState } from 'react'; -import Slider from '@material-ui/core/Slider'; -import { Typography } from '@material-ui/core'; -import { prepareChangeEvent } from './helpers'; -import { StyledRangeSliderForm } from './styled'; -import { ZABBIX_METRICS_WITH_PROGRESS } from '../../../constants'; - -const RangeSlider = ({ value, values, onChange }) => { - const widgetZabbixMetric = values.selectedZabbixMetric; - const marks = [ - { - value: 0, - label: '0%' - }, - { - value: 100, - label: '100%' - } - ]; - const [rangeValue, setRangeValue] = useState(value); - - const handleChange = (_, newValue) => setRangeValue(newValue); - const handleChangeCommited = (_, newValue) => { - onChange(prepareChangeEvent(newValue, 'array')); - }; - - const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes(widgetZabbixMetric); - const setAriaAttributeText = value => `${value}%`; - - return ( - <> - {checkMetricHasProgress && ( - - Range (%) - - - )} - - ); -}; - -export default RangeSlider; +import React, { useState } from 'react'; +import Slider from '@material-ui/core/Slider'; +import { Typography } from '@material-ui/core'; +import { prepareChangeEvent } from './helpers'; +import { StyledRangeSliderForm } from './styled'; +import { ZABBIX_METRICS_WITH_PROGRESS } from '../../../constants'; + +const RangeSlider = ({ value, values, onChange }) => { + const widgetZabbixMetric = values.selectedZabbixMetric; + const marks = [ + { + value: 0, + label: '0%' + }, + { + value: 100, + label: '100%' + } + ]; + const [rangeValue, setRangeValue] = useState(value); + + const handleChange = (_, newValue) => setRangeValue(newValue); + const handleChangeCommited = (_, newValue) => { + onChange(prepareChangeEvent(newValue, 'array')); + }; + + const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes( + widgetZabbixMetric + ); + const setAriaAttributeText = value => `${value}%`; + + return ( + <> + {checkMetricHasProgress && ( + + Range (%) + + + )} + + ); +}; + +export default RangeSlider; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/TimestampInput.js b/cogboard-webapp/src/components/widgets/dialogFields/TimestampInput.js new file mode 100644 index 000000000..dbee47d6d --- /dev/null +++ b/cogboard-webapp/src/components/widgets/dialogFields/TimestampInput.js @@ -0,0 +1,66 @@ +import React from 'react'; + +import { StyledValidationMessages } from '../../WidgetForm/styled'; +import { hasError } from '../../../utils/components'; +import MomentUtils from '@date-io/moment'; +import moment from 'moment-timezone'; +import { MuiPickersUtilsProvider } from '@material-ui/pickers'; +import { prepareChangeEvent } from './helpers'; +import CloseIcon from '@material-ui/icons/Close'; +import { + DatePickerWrapper, + StyledDateTimePicker, + StyledIconButton +} from './styled'; +import { DATE_TIME_FORMAT, MILLIS_IN_SECOND } from '../../../constants'; + +const TimestampInput = ({ + error, + dataCy, + value, + label, + onChange, + ...other +}) => { + const handleChange = date => + onChange(prepareChangeEvent(date && date.utc().unix(), 'number?')); + + return ( + + + + } + /> + + {Number.isInteger(value) && ( + handleChange(null)}> + + + )} + + ); +}; + +export default TimestampInput; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/ToDoListinput.js b/cogboard-webapp/src/components/widgets/dialogFields/ToDoListinput.js index 79f57e09f..ba5983490 100644 --- a/cogboard-webapp/src/components/widgets/dialogFields/ToDoListinput.js +++ b/cogboard-webapp/src/components/widgets/dialogFields/ToDoListinput.js @@ -1,179 +1,179 @@ -import React, { useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { v4 } from 'uuid'; -import { remove } from 'ramda'; -import { postWidgetContentUpdate } from '../../../utils/fetch'; -import { saveWidget } from '../../../actions/thunks'; -import { prepareChangeEvent, RenderDragableList } from './helpers'; -import { FormControl } from '@material-ui/core'; -import { Add, Check, Delete } from '@material-ui/icons'; -import { StyledFab, StyledInput, StyledFabGroup } from './styled'; - -const ToDoListInput = ({ value, values, onChange }) => { - const [formValueItemText, setFormValueItemText] = useState(''); - const [editMode, setEditMode] = useState(false); - const dispatch = useDispatch(); - const content = values.content || {}; - const handleChangeValItemText = event => - setFormValueItemText(event.target.value); - const selectedItems = content.selectedItems || []; - const widgetId = values.id || ''; - const [items, setItems] = useState(() => - (value || []).map(item => { - return { - id: item.id, - itemText: item.itemText - }; - }) - ); - - const resetInput = () => { - setFormValueItemText(''); - }; - - const onSaveClick = () => { - handleSave({ - itemText: formValueItemText - }); - }; - - const handleSave = item => { - let updatedItems; - - if (item.itemText.length === 0) { - return; - } - - if (editMode) { - updatedItems = items; - const updatedItemId = items.findIndex(el => el.id === editMode); - updatedItems[updatedItemId] = { - id: updatedItems[updatedItemId].id, - itemText: item.itemText - }; - setEditMode(false); - } else { - updatedItems = [ - ...items, - { - id: `item-${v4()}`, - itemText: item.itemText - } - ]; - } - - setItems(updatedItems); - onChange(prepareChangeEvent(updatedItems, 'array')); - resetInput(); - }; - - const onInputKeyDown = event => { - if (event.key === 'Enter') { - event.preventDefault(); - onSaveClick(); - } - }; - - const onClearClick = () => { - if (!selectedItems) return; - - const itemsToClear = new Set(selectedItems); - const filteredArray = items.filter(obj => !itemsToClear.has(obj.id)); - setItems(filteredArray); - onChange(prepareChangeEvent(filteredArray, 'array')); - - postWidgetContentUpdate({ - id: widgetId, - clearItems: true - }); - dispatch( - saveWidget({ - widgetId, - values: { ...values, toDoListItems: filteredArray } - }) - ); - }; - - const handleEdit = id => { - const editItem = items.find(el => el.id === id); - setFormValueItemText(editItem.itemText); - setEditMode(editItem.id); - }; - - const handleDelete = itemIndex => { - let itemList = remove(itemIndex, 1, items); - const itemId = items[itemIndex].id; - - setItems(itemList); - onChange(prepareChangeEvent(itemList, 'array')); - - if (selectedItems.includes(itemId)) { - postWidgetContentUpdate({ - id: widgetId, - selectedItem: itemId - }); - dispatch( - saveWidget({ widgetId, values: { ...values, toDoListItems: itemList } }) - ); - } - }; - - return ( - - - - - {editMode ? ( - <> - Save item - - ) : ( - <> - Add Item - - )} - - {selectedItems.length > 0 && ( - - <> - Clear Selected - - - )} - - - - ); -}; - -export default ToDoListInput; +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { v4 } from 'uuid'; +import { remove } from 'ramda'; +import { postWidgetContentUpdate } from '../../../utils/fetch'; +import { saveWidget } from '../../../actions/thunks'; +import { prepareChangeEvent, RenderDragableList } from './helpers'; +import { FormControl } from '@material-ui/core'; +import { Add, Check, Delete } from '@material-ui/icons'; +import { StyledFab, StyledInput, StyledFabGroup } from './styled'; + +const ToDoListInput = ({ value, values, onChange }) => { + const [formValueItemText, setFormValueItemText] = useState(''); + const [editMode, setEditMode] = useState(false); + const dispatch = useDispatch(); + const content = values.content || {}; + const handleChangeValItemText = event => + setFormValueItemText(event.target.value); + const selectedItems = content.selectedItems || []; + const widgetId = values.id || ''; + const [items, setItems] = useState(() => + (value || []).map(item => { + return { + id: item.id, + itemText: item.itemText + }; + }) + ); + + const resetInput = () => { + setFormValueItemText(''); + }; + + const onSaveClick = () => { + handleSave({ + itemText: formValueItemText + }); + }; + + const handleSave = item => { + let updatedItems; + + if (item.itemText.length === 0) { + return; + } + + if (editMode) { + updatedItems = items; + const updatedItemId = items.findIndex(el => el.id === editMode); + updatedItems[updatedItemId] = { + id: updatedItems[updatedItemId].id, + itemText: item.itemText + }; + setEditMode(false); + } else { + updatedItems = [ + ...items, + { + id: `item-${v4()}`, + itemText: item.itemText + } + ]; + } + + setItems(updatedItems); + onChange(prepareChangeEvent(updatedItems, 'array')); + resetInput(); + }; + + const onInputKeyDown = event => { + if (event.key === 'Enter') { + event.preventDefault(); + onSaveClick(); + } + }; + + const onClearClick = () => { + if (!selectedItems) return; + + const itemsToClear = new Set(selectedItems); + const filteredArray = items.filter(obj => !itemsToClear.has(obj.id)); + setItems(filteredArray); + onChange(prepareChangeEvent(filteredArray, 'array')); + + postWidgetContentUpdate({ + id: widgetId, + clearItems: true + }); + dispatch( + saveWidget({ + widgetId, + values: { ...values, toDoListItems: filteredArray } + }) + ); + }; + + const handleEdit = id => { + const editItem = items.find(el => el.id === id); + setFormValueItemText(editItem.itemText); + setEditMode(editItem.id); + }; + + const handleDelete = itemIndex => { + let itemList = remove(itemIndex, 1, items); + const itemId = items[itemIndex].id; + + setItems(itemList); + onChange(prepareChangeEvent(itemList, 'array')); + + if (selectedItems.includes(itemId)) { + postWidgetContentUpdate({ + id: widgetId, + selectedItem: itemId + }); + dispatch( + saveWidget({ widgetId, values: { ...values, toDoListItems: itemList } }) + ); + } + }; + + return ( + + + + + {editMode ? ( + <> + Save item + + ) : ( + <> + Add Item + + )} + + {selectedItems.length > 0 && ( + + <> + Clear Selected + + + )} + + + + ); +}; + +export default ToDoListInput; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/index.js b/cogboard-webapp/src/components/widgets/dialogFields/index.js index f1c3ba7c2..99d07a3ea 100644 --- a/cogboard-webapp/src/components/widgets/dialogFields/index.js +++ b/cogboard-webapp/src/components/widgets/dialogFields/index.js @@ -37,6 +37,9 @@ import RangeSlider from './RangeSlider'; import LinkListInput from './LinkListInput'; import ToDoListInput from './ToDoListinput'; import WidgetTypeField from './WidgetTypeField'; +import FileTextInput from './FileTextInput'; +import ParserTypeInput from './ParserTypeInput'; +import TimestampInput from './TimestampInput'; const dialogFields = { LabelField: { @@ -100,12 +103,33 @@ const dialogFields = { label: 'Token', validator: () => string() }, + SSHKeyField: { + component: FileTextInput, + name: 'sshKey', + label: 'SSH Private Key', + validator: () => + string() + .matches('^-----BEGIN ([A-Z]{1,} )*PRIVATE KEY-----\n', { + message: vm.SSH_KEY_BEGIN, + excludeEmptyString: true + }) + .matches('\n-----END ([A-Z]{1,} )*PRIVATE KEY-----\n$', { + message: vm.SSH_KEY_END, + excludeEmptyString: true + }) + }, + SSHKeyPassphraseField: { + component: PasswordInput, + name: 'sshKeyPassphrase', + label: 'SSH private key passphrase', + validator: () => string() + }, PublicURL: { component: TextInput, name: 'publicUrl', label: 'Public URL', validator: () => - string().matches(/^(http|https|ws|ftp):\/\/.*([:.]).*/, { + string().matches(/^(http|https|ws|ftp|ssh):\/\/.*([:.]).*/, { message: vm.INVALID_PUBLIC_URL(), excludeEmptyString: true }) @@ -251,7 +275,7 @@ const dialogFields = { name: 'url', label: 'URL', validator: () => - string().matches(/^(http|https|ws|ftp):\/\/.*([:.]).*/, { + string().matches(/^(http|https|ws|ftp|ssh):\/\/.*([:.]).*/, { message: vm.INVALID_URL(), excludeEmptyString: true }) @@ -564,6 +588,76 @@ const dialogFields = { name: 'linkListItems', initialValue: [], validator: () => array().ensure() + }, + LogLinesField: { + component: NumberInput, + name: 'logLinesField', + label: 'Maximum number of lines to return', + initialValue: 1000, + min: 1, + step: 1, + pattern: /\d*/, + valueUpdater: transformMinValue(), + validator: ({ min, max }) => + number() + .min(min, vm.NUMBER_MIN('Lines', min)) + .max(max, vm.NUMBER_MAX('Lines', max)) + .required(vm.FIELD_REQUIRED()) + }, + LogFileSizeField: { + component: NumberInput, + name: 'logFileSizeField', + label: 'Log file size limit [MB]', + initialValue: 50, + min: 1, + step: 1, + pattern: /\d*/, + valueUpdater: transformMinValue(), + validator: ({ min, max }) => + number() + .min(min, vm.NUMBER_MIN('File size [MB]', min)) + .max(max, vm.NUMBER_MAX('File size [MB]', max)) + .required(vm.FIELD_REQUIRED()) + }, + LogRecordExpirationField: { + component: NumberInput, + name: 'logRecordExpirationField', + label: 'Log record expiration period [days]', + initialValue: 5, + min: 1, + step: 1, + pattern: /\d*/, + valueUpdater: transformMinValue(), + validator: ({ min, max }) => + number() + .min(min, vm.NUMBER_MIN('Days', min)) + .max(max, vm.NUMBER_MAX('Days', max)) + .required(vm.FIELD_REQUIRED()) + }, + RegExpField: { + component: MultilineTextInput, + name: 'regExp', + label: 'Regular expression', + validator: () => string().required(vm.FIELD_REQUIRED()) + }, + ReasonField: { + component: TextInput, + name: 'reasonField', + label: 'Reason', + validator: () => string() + }, + EndTimestampField: { + component: TimestampInput, + name: 'endTimestamp', + label: 'End date (optional)', + initialValue: null, + validator: () => number().nullable() + }, + LogParserField: { + component: ParserTypeInput, + name: 'logParserField', + label: 'Log parser type', + validator: () => string() } }; diff --git a/cogboard-webapp/src/components/widgets/dialogFields/styled.js b/cogboard-webapp/src/components/widgets/dialogFields/styled.js index 4d56781db..c649a0a7e 100644 --- a/cogboard-webapp/src/components/widgets/dialogFields/styled.js +++ b/cogboard-webapp/src/components/widgets/dialogFields/styled.js @@ -2,7 +2,16 @@ import styled from '@emotion/styled/macro'; import NumberInput from './NumberInput'; import IntegerInput from './IntegerInput'; import { COLORS } from '../../../constants'; -import { Box, Input, Fab, List, FormControl } from '@material-ui/core'; +import { + Box, + Input, + Fab, + List, + FormControl, + Button, + IconButton +} from '@material-ui/core'; +import { DateTimePicker } from '@material-ui/pickers'; export const StyledNumberInput = styled(NumberInput)` flex-basis: calc(50% - 18px); @@ -152,3 +161,50 @@ export const StyledMultiLineWrapper = styled.div` flex: 1 0 auto; } `; + +export const StyledHorizontalStack = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +`; + +export const StyledVerticalStack = styled.div` + margin: 16px 0 8px 0; + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const StyledLabel = styled.p` + font-size: 1rem; + margin: 0; + color: rgba(255, 255, 255, 0.7); + transform: translate(0, 1.5px) scale(0.75); + transform-origin: top left; + font-weight: 400; + line-height: 1; + letter-spacing: 0.00938em; +`; + +export const DeleteButton = styled(Button)` + background-color: ${COLORS.RED}; + + &:hover { + background-color: ${COLORS.DARK_RED}; + } +`; + +export const DatePickerWrapper = styled.div` + position: relative; +`; + +export const StyledIconButton = styled(IconButton)` + position: absolute; + right: 0; + top: 24px; +`; + +export const StyledDateTimePicker = styled(DateTimePicker)` + width: 100%; +`; diff --git a/cogboard-webapp/src/components/widgets/index.js b/cogboard-webapp/src/components/widgets/index.js index c432b5217..1fb3cf7eb 100644 --- a/cogboard-webapp/src/components/widgets/index.js +++ b/cogboard-webapp/src/components/widgets/index.js @@ -15,6 +15,7 @@ import AemBundleInfoWidget from './types/AemBundleInfoWidget'; import ZabbixWidget from './types/ZabbixWidget'; import LinkListWidget from './types/LinkListWidget'; import ToDoListWidget from './types/ToDoListWidget'; +import LogViewerWidget from './types/LogViewerWidget'; const widgetTypes = { WhiteSpaceWidget: { @@ -200,6 +201,25 @@ const widgetTypes = { component: LinkListWidget, dialogFields: ['LinkListItems'], initialStatus: 'NONE' + }, + LogViewerWidget: { + component: LogViewerWidget, + dialogFields: [ + 'EndpointField', + 'SchedulePeriod', + 'Path', + 'LogLinesField', + 'LogFileSizeField', + 'LogRecordExpirationField', + 'LogParserField' + ], + validationConstraints: { + SchedulePeriod: { min: 3 }, + LogLinesField: { min: 1, max: 10_000_000 }, + LogFileSizeField: { min: 1, max: 1024 }, + LogRecordExpirationField: { min: 1, max: 1000 } + }, + initialStatus: 'NONE' } }; diff --git a/cogboard-webapp/src/components/widgets/types/LinkListWidget/index.js b/cogboard-webapp/src/components/widgets/types/LinkListWidget/index.js index dd9a4a49e..95ea23a81 100644 --- a/cogboard-webapp/src/components/widgets/types/LinkListWidget/index.js +++ b/cogboard-webapp/src/components/widgets/types/LinkListWidget/index.js @@ -1,44 +1,44 @@ -import React from 'react'; -import { array } from 'prop-types'; -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; -import { Link } from '@material-ui/core'; -import { StyledList, StyledListItem } from './styled'; -import { StyledNoItemsInfo } from '../../../Widget/styled'; - -const LinkListWidget = ({ linkListItems }) => { - return ( - <> - {linkListItems.length > 0 ? ( - - {linkListItems.map((item, id) => ( - - - {item.linkTitle} - - - ))} - - ) : ( - - -

Links List Empty

-
- )} - - ); -}; - -LinkListWidget.propTypes = { - linkListItems: array -}; - -LinkListWidget.defaultProps = { - linkListItems: [] -}; - -export default LinkListWidget; +import React from 'react'; +import { array } from 'prop-types'; +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import { Link } from '@material-ui/core'; +import { StyledList, StyledListItem } from './styled'; +import { StyledNoItemsInfo } from '../../../Widget/styled'; + +const LinkListWidget = ({ linkListItems }) => { + return ( + <> + {linkListItems.length > 0 ? ( + + {linkListItems.map((item, id) => ( + + + {item.linkTitle} + + + ))} + + ) : ( + + +

Links List Empty

+
+ )} + + ); +}; + +LinkListWidget.propTypes = { + linkListItems: array +}; + +LinkListWidget.defaultProps = { + linkListItems: [] +}; + +export default LinkListWidget; diff --git a/cogboard-webapp/src/components/widgets/types/LinkListWidget/styled.js b/cogboard-webapp/src/components/widgets/types/LinkListWidget/styled.js index e80d4aa36..7498099fc 100644 --- a/cogboard-webapp/src/components/widgets/types/LinkListWidget/styled.js +++ b/cogboard-webapp/src/components/widgets/types/LinkListWidget/styled.js @@ -1,13 +1,13 @@ -import styled from '@emotion/styled/macro'; - -import { List, ListItem } from '@material-ui/core'; - -export const StyledListItem = styled(ListItem)` - a:hover { - text-deecoration: underline; - } -`; - -export const StyledList = styled(List)` - padding: 0; -`; +import styled from '@emotion/styled/macro'; + +import { List, ListItem } from '@material-ui/core'; + +export const StyledListItem = styled(ListItem)` + a:hover { + text-deecoration: underline; + } +`; + +export const StyledList = styled(List)` + padding: 0; +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/LogEntry.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/LogEntry.js new file mode 100644 index 000000000..8b7049c55 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/LogEntry.js @@ -0,0 +1,153 @@ +import React, { useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { toggleLogsViewerLog } from '../../../../../actions/actionCreators'; +import { getIsAuthenticated } from '../../../../../selectors'; +import { + string, + number, + bool, + shape, + oneOfType, + arrayOf, + func +} from 'prop-types'; +import LogsViewerContext from '../context'; +import { getGridTemplate, highlightText } from './helpers'; + +import { AccordionSummary, AccordionDetails, Tooltip } from '@material-ui/core'; +import { FilterList, Schedule, ExpandMore } from '@material-ui/icons'; +import { + GridSchema, + Text, + CustomAccordion, + VariableGridSchema, + HighlightedText, + HighlightMark, + SimilarLogsButtonsContainer, + FilterSimilarLogsButton, + QuarantineSimilarLogsButton, + LogMargin +} from './styled'; +import TextWithCopyButton from './TextWithCopyButton'; + +const LogEntry = ({ + onToggle, + id, + type, + date, + variableData, + template, + search, + highlight +}) => { + const dispatch = useDispatch(); + const isAuthenticated = useSelector(getIsAuthenticated); + const { wid, setFilter, setQuarantine } = useContext(LogsViewerContext); + + const expandedList = + useSelector(store => store.widgets.logsViewersCache[wid]?.expandedLogs) || + []; + const isExpanded = expandedList.includes(id); + + const toggleExpanded = () => { + dispatch(toggleLogsViewerLog({ wid, logid: id })); + onToggle(); + }; + + const getLastVariableHeader = () => + variableData[variableData.length - 1]?.header ?? ''; + + const VariablePart = ({ description }) => { + const variableFieldsTemplate = getGridTemplate(template); + return ( + + {variableData.map((entry, index) => { + const entryText = description ? entry.description : entry.header; + return ( + + ); + })} + + ); + }; + + return ( + + + } + > + {highlight && } + + + {type?.toUpperCase()} + + + + + + + + + + + setFilter(getLastVariableHeader())} + > + + + + {isAuthenticated && ( + + setQuarantine(getLastVariableHeader())} + > + + + + )} + + + + + + ); +}; + +export default LogEntry; + +LogEntry.propTypes = { + id: string.isRequired, + type: string, + date: string.isRequired, + variableData: arrayOf( + shape({ + header: oneOfType([string, number, bool]).isRequired, + description: oneOfType([string, number, bool]).isRequired + }) + ), + template: arrayOf(string), + search: string, + highlight: bool, + onToggle: func +}; + +LogEntry.defaultProps = { + onToggle: () => {}, + type: 'info', + variableData: [], + template: [], + search: undefined, + highlight: false +}; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/TextWithCopyButton.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/TextWithCopyButton.js new file mode 100644 index 000000000..1569a4691 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/TextWithCopyButton.js @@ -0,0 +1,59 @@ +import React, { useState, useRef } from 'react'; + +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import { StyledCopyButton, Text, TextWithCopyButtonContainer } from './styled'; +import { Tooltip } from '@material-ui/core'; + +const tooltipMessages = { + standard: 'Copy to clipboard', + copied: 'Copied!', + error: 'Error: could not copy' +}; + +const TextWithCopyButton = ({ text, ...props }) => { + const [tooltipMsg, setTooltipMsg] = useState(tooltipMessages.standard); + const textRef = useRef(null); + + const setStandardTooltipMsg = () => + setTimeout(() => setTooltipMsg(tooltipMessages.standard), 200); + const setCopiedTooltipMsg = () => setTooltipMsg(tooltipMessages.copied); + const setErrorTooltipMsg = () => setTooltipMsg(tooltipMessages.error); + + const handleCopy = () => { + try { + const selection = window.getSelection(); + selection.removeAllRanges(); + + const range = document.createRange(); + range.selectNodeContents(textRef.current); + + selection.addRange(range); + document.execCommand('copy'); + selection.removeAllRanges(); + setCopiedTooltipMsg(); + } catch (err) { + setErrorTooltipMsg(); + } + }; + + return ( + + + { + e.stopPropagation(); + handleCopy(); + }} + > + + + + + {text} + + + ); +}; + +export default TextWithCopyButton; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByDateSpan.test.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByDateSpan.test.js new file mode 100644 index 000000000..55b129814 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByDateSpan.test.js @@ -0,0 +1,72 @@ +import { filterByDateSpan } from './helpers'; +import moment from 'moment-timezone'; + +const log = { + _id: '61b4c753bc103d391657c49c', + seq: 228, + insertedOn: 1639237459, + type: 'info', + variableData: [] +}; + +it('filters out log without date', () => + expect( + filterByDateSpan(log, { + begin: moment('2021-12-22T10:00:00.000Z'), + end: moment('2021-12-22T20:00:00.000Z') + }) + ).toBeFalsy()); + +it('should filter in when no date span is provided', () => + expect(filterByDateSpan(log, { begin: null, end: null })).toBeTruthy()); + +it('filters by begin date', () => { + expect( + filterByDateSpan( + { ...log, date: '2021-12-22T15:00:00.000' }, + { begin: moment('2021-12-22T10:00:00.000Z'), end: null } + ) + ).toBeTruthy(); + expect( + filterByDateSpan( + { ...log, date: '2021-12-22T20:00:00' }, + { begin: moment('2021-12-22T20:00:00.000Z'), end: null } + ) + ).toBeFalsy(); +}); + +it('filters by end date', () => { + expect( + filterByDateSpan( + { ...log, date: '2021-12-22T15:00:00.000' }, + { begin: null, end: moment('2021-12-22T20:00:00.000Z') } + ) + ).toBeTruthy(); + expect( + filterByDateSpan( + { ...log, date: '2021-12-22T15:00:00.000' }, + { begin: null, end: moment('2021-12-22T10:00:00.000Z') } + ) + ).toBeFalsy(); +}); + +it('filters by date span', () => { + expect( + filterByDateSpan( + { ...log, date: '2021-12-22T15:00:00.000' }, + { + begin: moment('2021-12-22T10:00:00.000Z'), + end: moment('2021-12-22T20:00:00.000Z') + } + ) + ).toBeTruthy(); + expect( + filterByDateSpan( + { ...log, date: '2021-12-22T15:00:00.000' }, + { + begin: moment('2021-12-22T19:00:00.000Z'), + end: moment('2021-12-22T20:00:00.000Z') + } + ) + ).toBeFalsy(); +}); diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByLevel.test.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByLevel.test.js new file mode 100644 index 000000000..184d6c0e4 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByLevel.test.js @@ -0,0 +1,21 @@ +import { filterByLevel } from './helpers'; + +const log = { + _id: '61b4c753bc103d391657c49c', + seq: 228, + insertedOn: 1639237459, + date: '2021-12-11T15:44:00', + variableData: [] +}; + +it('lets through log with unknown level', () => + expect(filterByLevel({ ...log, type: 'smth' }, 'error')).toBeTruthy()); + +it('is not case sensitive', () => + expect(filterByLevel({ ...log, type: 'eRroR' }, 'ErROr')).toBeTruthy()); + +it('lets through logs with greater level', () => + expect(filterByLevel({ ...log, type: 'error' }, 'info')).toBeTruthy()); + +it('filters out logs with lower level', () => + expect(filterByLevel({ ...log, type: 'info' }, 'warning')).toBeFalsy()); diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByRegExp.test.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByRegExp.test.js new file mode 100644 index 000000000..37ce8700a --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/filterByRegExp.test.js @@ -0,0 +1,89 @@ +import { filterByRegExp } from './helpers'; + +const logHeader = { + _id: '61b4c753bc103d391657c49c', + seq: 228, + insertedOn: 1639237459, + date: '2021-12-11T15:44:00', + type: 'DEBUG' +}; + +const log = { + ...logHeader, + variableData: [ + { + header: 'FelixStartLevel', + description: 'No description' + }, + { + header: 'id velit. vel pretium. ipsum suscipit', + description: 'No message description' + } + ] +}; +const emptyLog = { + ...logHeader, + variableData: [ + { + header: '', + description: '' + } + ] +}; + +const filters = { + startsWithA: { + id: 'filter-3ea57f8b-6c1c-4e2f-bbc5-760aa0526e8e', + checked: true, + regExp: '^a', + label: 'starts with a' + }, + velit: { + id: 'filter-2afee54b-8ab6-4a0c-81db-9c92e7bf5203', + checked: true, + label: 'velit', + regExp: 'velit' + }, + startsWithF: { + id: 'filter-b9b72673-e6ca-481d-996a-293527032764', + checked: true, + label: 'starts with F', + regExp: '^F' + }, + endWithSuscipit: { + id: 'filter-c9dac795-43c6-4a57-9329-5dd1573c9bf4', + checked: true, + label: 'ends with suscipit', + regExp: 'suscipit$' + } +}; + +it('shows log without filters', () => + expect(filterByRegExp(log, [])).toBeTruthy()); + +it('filters with multiple rules', () => + expect( + filterByRegExp(log, [filters.startsWithA, filters.velit]) + ).toBeFalsy()); + +it('filters with multiple rules 2', () => + expect( + filterByRegExp(log, [ + filters.velit, + filters.startsWithF, + filters.endWithSuscipit + ]) + ).toBeTruthy()); + +it('checks if filter is enabled', () => + expect( + filterByRegExp(log, [ + { ...filters.startsWithA, checked: false }, + filters.velit + ]) + ).toBeTruthy()); + +it('filters empty logs', () => + expect( + filterByRegExp(emptyLog, [filters.startsWithA, filters.endWithSuscipit]) + ).toBeFalsy()); diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/helpers.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/helpers.js new file mode 100644 index 000000000..9feb7344b --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/helpers.js @@ -0,0 +1,70 @@ +import logLevels from '../logLevels'; + +export const getGridTemplate = columnNames => { + const widths = columnNames?.map(name => + name.toLowerCase() === 'message' ? '3fr ' : '1fr ' + ); + return widths?.reduce((acc, current) => acc + current, '') || ''; +}; + +export const filterByLevel = (log, level) => { + const logLevel = logLevels[log.type.toLowerCase()]?.level; + const selectedLevel = logLevels[level.toLowerCase()].level; + return logLevel ? logLevel >= selectedLevel : true; +}; + +const getLogTexts = log => { + const texts = []; + // loop through log variable columns + log.variableData.forEach(({ header, description }) => { + texts.push(header); + texts.push(description); + }); + return texts; +}; + +export const isLogHighlighted = (log, search) => + search && getLogTexts(log).some(text => text.match(new RegExp(search, 'i'))) + ? true + : false; + +export const highlightText = (text, search, Component) => + search + ? text + .split(new RegExp(`(${search})`, 'gi')) + .map((part, index) => + part.toLowerCase() === search.toLowerCase() ? ( + {part} + ) : ( + {part} + ) + ) + : text; + +export const filterByRegExp = (log, filters) => + filters + .filter(f => f.checked) + .every(({ regExp }) => + getLogTexts(log).some(text => text.match(new RegExp(regExp))) + ); + +/* + log: string? + begin: momentjs-object? + end: momentjs-object? +*/ +export const filterByDateSpan = (log, { begin, end }) => { + const isEmptyLogDate = !log.date; + const isNoDateSpanSpecified = !begin && !end; + + if (isEmptyLogDate) { + return isNoDateSpanSpecified; + } + + const date = new Date(log.date); + + if (begin && date < begin) return false; + if (end && date > end) return false; + + return true; +}; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/highlightning.test.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/highlightning.test.js new file mode 100644 index 000000000..42d1c16d8 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/highlightning.test.js @@ -0,0 +1,38 @@ +import { isLogHighlighted } from './helpers'; + +const logHeader = { + _id: '61b4c753bc103d391657c49c', + seq: 228, + insertedOn: 1639237459, + date: '2021-12-11T15:44:00', + type: 'DEBUG', + variableData: null +}; + +describe('log highlighting', () => { + const variableData = [ + { + header: 'FelixStartLevel', + description: 'No description' + }, + { + header: 'id velit. vel pretium. ipsum suscipit', + description: 'No message description' + } + ]; + const log = { ...logHeader, variableData }; + + it('should highlight logs', () => { + expect(isLogHighlighted(log, 'eli')).toEqual(true); + expect(isLogHighlighted(log, 'ELI')).toEqual(true); + expect(isLogHighlighted(log, 'id velit')).toEqual(true); + expect(isLogHighlighted(log, 'cipit')).toEqual(true); + expect(isLogHighlighted(log, 'FelixStartLevel')).toEqual(true); + expect(isLogHighlighted(log, 'description')).toEqual(true); + expect(isLogHighlighted(log, 'message')).toEqual(true); + }); + it('should not highlight logs', () => { + expect(isLogHighlighted(log, 'val')).toEqual(false); + expect(isLogHighlighted(log, '')).toBeFalsy(); + }); +}); diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/index.js new file mode 100644 index 000000000..5741d4427 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/index.js @@ -0,0 +1,136 @@ +import React, { useRef, useEffect } from 'react'; +import { getGridTemplate, isLogHighlighted } from './helpers'; +import { useTheme } from '@material-ui/core'; +import LogEntry from './LogEntry'; +import { + Container, + Header, + GridSchema, + ColumnTitle, + LogsWrapper, + StyledVirtuoso, + VariableGridSchema +} from './styled'; + +const logHeight = 28; + +export default function LogList({ + logs, + template, + search, + shouldFollowLogs, + handleFollowChange, + logListFull +}) { + const theme = useTheme(); + const virtuosoRef = useRef(null); + const scrollerRef = useRef(null); + const prevScrollPos = useRef(0); + const prevLastLogId = useRef(null); + const prevLogsLength = useRef(null); + + const logsCountOffset = logs.length - prevLogsLength.current; + + const VariableLogListHeader = () => ( + + {template?.map((name, index) => ( + {name} + ))} + + ); + + const handleScroll = () => { + const scrollerOffset = + scrollerRef.current.scrollTop - prevScrollPos.current; + + const isScrollingUpward = + scrollerOffset < 0 && scrollerOffset < logHeight * logsCountOffset; + if (isScrollingUpward) { + handleFollowChange(false); + } + prevScrollPos.current = scrollerRef.current.scrollTop; + prevLogsLength.current = logs.length; + }; + + useEffect(() => { + if (shouldFollowLogs) { + virtuosoRef.current.scrollToIndex({ + index: logs.length - 1, + align: 'bottom', + behavior: 'smooth' + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldFollowLogs]); + + useEffect(() => { + if (!shouldFollowLogs && logListFull) { + if (prevLastLogId.current !== null) { + let offset = 0; + for (let i = logs.length - 1; i >= 0; i--) { + if (logs[i]._id === prevLastLogId.current) { + break; + } + offset += 1; + } + + const COULD_NOT_FIND_LOG = offset === logs.length; + if (!COULD_NOT_FIND_LOG) { + offset -= logsCountOffset; + + virtuosoRef.current.scrollTo({ + top: prevScrollPos.current - offset * logHeight, + behavior: 'instant' + }); + } + } + } + prevLastLogId.current = logs.length > 0 && logs[logs.length - 1]._id; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logs]); + + const getLogByIndex = index => ( + + ); + + const MemoLogEntry = React.memo(({ log }) => ( + handleFollowChange(false)} + /> + )); + + return ( + +
+ + Level + Date + + +
+ + + (scrollerRef.current = ref)} + isScrolling={handleScroll} + totalCount={logs.length} + increaseViewportBy={300} // defines loading overlap (in pixels) + itemContent={getLogByIndex} + atBottomThreshold={0} + followOutput={isAtBottom => + shouldFollowLogs && !isAtBottom ? 'smooth' : false + } + /> + +
+ ); +} diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/styled.js new file mode 100644 index 000000000..173e77112 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogList/styled.js @@ -0,0 +1,182 @@ +import styled from '@emotion/styled/macro'; +import { Virtuoso } from 'react-virtuoso'; +import { COLORS } from '../../../../../constants'; +import { Typography, Accordion, IconButton } from '@material-ui/core'; +import logLevels from '../logLevels'; + +export const Container = styled.div` + flex-grow: 1; + display: grid; + grid-template-rows: auto 1fr; +`; + +export const Header = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + border-left: none; + border-right: none; + padding: 0.25em 0; +`; + +export const GridSchema = styled.div` + width: 100%; + display: grid; + grid-template-columns: 70px 150px 1fr 56px; + padding: 0 10px; + justify-items: flex-start; +`; +export const VariableGridSchema = styled.div( + props => ` + width: 100%; + display: grid; + justify-items: flex-start; + align-items: flex-start; + grid-template-columns: ${props.template}; + ${props.skipColumns ? 'grid-column: 3 / 4;' : ''} + ` +); + +export const ColumnTitle = styled(Typography)` + font-weight: 600; + font-size: 0.85rem; +`; + +export const Text = styled(Typography)(({ type }) => { + let logTypeStyles = ``; + if (type) { + logTypeStyles = ` + font-weight: 500; + color: ${logLevels[type.toLowerCase()]?.color || COLORS.WHITE}; + `; + } + + return ` + user-select: auto; + line-height: 19px; + font-size: 0.8rem; + font-weight: 400; + ${logTypeStyles} + `; +}); + +export const HighlightedText = styled.span` + color: ${COLORS.BLACK}; + background-color: ${COLORS.YELLOW}; +`; + +export const LogsWrapper = styled.div` + padding-top: 6px; + height: 100%; +`; + +export const StyledVirtuoso = styled(Virtuoso)` + height: 100%; +`; + +export const CustomAccordion = styled(Accordion)` + margin: 0; + box-shadow: none; + + &.MuiPaper-root { + background-color: ${COLORS.LIGHT_SHADE}; + overflow: hidden; + } + &&.Mui-expanded { + margin: 0; + } + + .MuiAccordionSummary-root { + padding: 0.25em; + min-height: unset; + } + + .MuiAccordionSummary-root .MuiIconButton-root { + position: absolute; + top: 0; + right: 1em; + } + + .MuiAccordionSummary-root, + .MuiAccordionSummary-root .MuiButtonBase-root { + padding: unset; + } + + .MuiAccordionSummary-content { + margin: 0; + padding: 0.25em 0; + } + .MuiAccordionSummary-content.Mui-expanded { + min-height: unset; + } + + .MuiAccordionDetails-root { + padding: 0.25em 0; + background-color: ${COLORS.DARK_SHADE}; + } +`; + +export const HighlightMark = styled.div` + position: absolute; + left: -0.6rem; + top: -0.7rem; + height: 1.7rem; + width: 1rem; + transform: rotate(45deg); + background-color: ${COLORS.YELLOW}; +`; + +export const SimilarLogsButtonsContainer = styled.div` + display: flex; + justify-self: end; + flex-direction: column; + align-items: flex-end; + margin: 8px 0; + gap: 8px; + box-sizing: border-box; +`; + +export const FilterSimilarLogsButton = styled(IconButton)` + background-color: ${COLORS.LIGHT_SHADE}; +`; +FilterSimilarLogsButton.defaultProps = { + size: 'small' +}; + +export const QuarantineSimilarLogsButton = styled(IconButton)` + background-color: ${COLORS.RED}; + &:hover { + background-color: ${COLORS.DARK_RED}; + } +`; +QuarantineSimilarLogsButton.defaultProps = { + size: 'small' +}; + +export const StyledCopyButton = styled(IconButton)` + position: absolute; + &&&& { + top: 0.1rem; + right: -1.15rem; + } + padding: 0; + font-size: 14px; + + visiblity: hidden; + opacity: 0; + transition: opacity 0.2s ease-in-out; +`; +StyledCopyButton.defaultProps = { + size: 'small' +}; + +export const TextWithCopyButtonContainer = styled.div` + position: relative; + + &:hover ${StyledCopyButton} { + opacity: 1; + visiblity: visible; + } +`; + +export const LogMargin = styled.div` + padding: 1px 0; +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogsViewer.md b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogsViewer.md new file mode 100644 index 000000000..823cd6f2a --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/LogsViewer.md @@ -0,0 +1,93 @@ +# Logs Viewer + +- `widgetLocalStorage` \* uses browser local storage with widget id as a key +- `searchFilter` - state of searchbox highlight (not to be confused with state of searchbox input) +- `template` - log template (array of column names, does not include `type` and `date`) + +## index + +- `newLogs` - logs delivered through knotx, new every iteration +- `storedLogs` - all logs in app; first iteration of logs is delivered through rest api, then appends logs from `newLogs` + +## Toolbar + +### SearchInput + +Has its own state. If input contains at least `minLetters` characters, value will be sent to higlight mechanism. + +### FilterPicker + +Uses local storage of the browser. + +- `filters` + - ui disabled when no filter is defined. +- `logLevel` +- `advanced` - filters management + +### DateRangePicker + +Uses local storage of the browser and `momentjs` library. Ignores seconds. + +Dates (begin and end) are held in state as `momentjs` objects. Each time value changes, it is saved as `string` in browser local storage. When it is loaded from local storage(eg. Logs Viewer reload) it is converted back to `momentjs` object. + +Log dates are strings. + +### Clear logs + +Hides all logs that have been delivered before usage. +Sets begin date of `DateRangePicker` to arrival date of last log. + +## LogList + +Displays logs and column names. +Will not render if logs are not provided (template required). + +Loglist flow is straight forward untill count of logs meets maximum number of logs. Then new logs are pushing old ones upward and you have to manually compensate to prevent screen shaking. Component remembers last: + +- `prevScrollPos` - previous scroll position +- `prevLastLogId` - previous last log on the list +- `prevLogsLength` - previous logs length + +### On logs change: + +It finds where is lastLog after adding new logs (offset). +It subtracts new logs count from offset because +Offset != newLogsCount. +Depending on filters, there might be less logs after adding new logs - old logs mathed filter, new ones don't). +Then it updates last log id (it won't update logsCount, because this function will call onScroll and it needs old logsCount value). + +### On scroll: + +- `scrollerOffset` < 0 + + it moved upward + +- `scrollerOffset` < `logHeight` \* `logsCountOffset` + + it moved more than moving from 'on logs change' could + +### VariableGridSchema + +Component which provides equal columns widths for `Header` and `LogEntry`. + +- `getGridTemplate` - by default column gets `1fr` of space, but `message` column will receive `3fr`. Returns `grid-template-columns`. + +### Logs + +Virtuoso is used to virtualize the list. (https://virtuoso.dev) + +### Highlighting + +There is double check for highlight - one for marker in the left-upper corner (`isLogHighlighted`), second used in `LogEntry` for text highlighting (`highlightText`). + +### Following logs + +Virtuoso handles scrolling. It is triggered only when logs.length changes. When log limit isn't met it works, when it is it doesn't need to work. There is manual scroll to the last log on following start. + +### LogEntry + +Every log column consists of `header` (accordion summary) and `description` (accordion details). + +Outer container cannot have margins (wrong virtuoso calculations). + +Copying feature uses depreciated functions. Recomended ones wouldn't work on unsecured HTTP serwer (w/o HTTPS). There is try catch to remain app stability if those functions were to be removed from browser. diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/CustomPicker.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/CustomPicker.js new file mode 100644 index 000000000..fa237d0b7 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/CustomPicker.js @@ -0,0 +1,37 @@ +import React from 'react'; + +import CloseIcon from '@material-ui/icons/Close'; +import { + PickerWrapper, + StyledIconButton, + CustomDateTimePicker +} from './styled'; +import { DATE_TIME_FORMAT } from '../../../../../../constants'; + +const CustomPicker = ({ id, value, onChange, label, ...props }) => { + const handleChange = data => onChange(data?.seconds(0).milliseconds(0)); + return ( + + + {value && ( + onChange(null)} + data-cy={`date-time-picker-${id}-clear`} + > + + + )} + + ); +}; + +export default CustomPicker; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/getDateSpan.test.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/getDateSpan.test.js new file mode 100644 index 000000000..e28df5b0c --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/getDateSpan.test.js @@ -0,0 +1,22 @@ +import moment from 'moment-timezone'; +import { getDateSpan } from './helpers'; + +const time20 = '2021-12-22T20:00:00.000Z'; +const time22 = '2021-12-22T22:00:00.000Z'; + +const widgetLocalStorage = { + get: () => ({ dateSpan: { begin: time20, end: time22 } }) +}; +const emptyWidgetLocalStorage = { get: () => ({}) }; + +it('returns dates as momentjs object', () => + expect(getDateSpan(widgetLocalStorage)).toEqual({ + begin: moment(time20), + end: moment(time22) + })); + +it('returns null when no date defined', () => + expect(getDateSpan(emptyWidgetLocalStorage)).toEqual({ + begin: null, + end: null + })); diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/helpers.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/helpers.js new file mode 100644 index 000000000..3f23d6c2b --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/helpers.js @@ -0,0 +1,21 @@ +import moment from 'moment-timezone'; + +export const saveDateSpan = ( + { get: localStorage, set: setLocalStorage }, + dateSpan +) => { + const newWidgetData = { ...localStorage(), dateSpan: dateSpan }; + setLocalStorage(newWidgetData); +}; + +export const getDateSpan = widgetLocalStorage => { + const dateSpan = widgetLocalStorage.get()?.dateSpan; + if (!dateSpan) return { begin: null, end: null }; + + const begin = dateSpan.begin; + const end = dateSpan.end; + return { + begin: begin ? moment(begin) : null, + end: end ? moment(end) : null + }; +}; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/index.js new file mode 100644 index 000000000..a82e63f3c --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/index.js @@ -0,0 +1,54 @@ +import React from 'react'; +import moment from 'moment-timezone'; +import MomentUtils from '@date-io/moment'; +import { getDateSpan, saveDateSpan } from './helpers'; + +import { MuiPickersUtilsProvider } from '@material-ui/pickers'; +import UpdateIcon from '@material-ui/icons/Update'; +import ToggleIconButton from '../ToggleIconButton'; +import CustomPicker from './CustomPicker'; +import { DatePickerWrapper } from './styled'; + +const DateRangePicker = ({ widgetLocalStorage, lastLog }) => { + const { begin, end } = getDateSpan(widgetLocalStorage); + + const handleBeginChange = date => + saveDateSpan(widgetLocalStorage, { begin: date, end }); + const handleEndChange = date => + saveDateSpan(widgetLocalStorage, { begin, end: date }); + + const handleClearLogs = () => { + const date = lastLog?.date; + if (date) { + const beginDate = moment(date).add(1, 'seconds'); + saveDateSpan(widgetLocalStorage, { begin: beginDate, end: null }); + } + }; + + return ( + + + + + + + + ); +}; + +export default DateRangePicker; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/styled.js new file mode 100644 index 000000000..b132d18ca --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/DateRangePicker/styled.js @@ -0,0 +1,27 @@ +import styled from '@emotion/styled/macro'; +import { IconButton } from '@material-ui/core'; +import { DateTimePicker } from '@material-ui/pickers'; + +export const PickerWrapper = styled.div` + position: relative; +`; + +export const StyledIconButton = styled(IconButton)` + position: absolute; + right: 0; + bottom: 2px; +`; +StyledIconButton.defaultProps = { + size: 'small', + variant: 'outlined' +}; + +export const CustomDateTimePicker = styled(DateTimePicker)` + width: 10rem; +`; + +export const DatePickerWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: flex-end; +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/EditFilter.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/EditFilter.js new file mode 100644 index 000000000..9565fcf63 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/EditFilter.js @@ -0,0 +1,46 @@ +import React from 'react'; + +import { useToggle } from '../../../../../../../hooks'; + +import { IconButton, Tooltip } from '@material-ui/core'; +import { Build } from '@material-ui/icons'; +import AppDialog from '../../../../../../AppDialog'; +import FilterForm from './FilterForm'; + +const EditFilter = ({ id, filters, editAction }) => { + const [dialogOpened, openDialog, handleDialogClose] = useToggle(); + const filterData = filters.find(filter => filter.id === id); + + const handleSubmit = values => { + editAction({ id, values }); + handleDialogClose(); + }; + + return ( + <> + + + + + + + {filterData && ( + + )} + + + ); +}; + +export default EditFilter; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/FilterForm.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/FilterForm.js new file mode 100644 index 000000000..ec06b8038 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/FilterForm.js @@ -0,0 +1,74 @@ +import React, { useEffect, useContext } from 'react'; +import { createValidationSchema } from '../../../../../../validation'; +import { useFormData } from '../../../../../../../hooks'; + +import { Button } from '@material-ui/core'; +import DynamicForm from '../../../../../../DynamicForm'; +import { StyledCancelButton } from './styled'; +import dialogFields from '../../../../../dialogFields'; +import LogsViewerContext from '../../../context'; + +const FilterForm = ({ + filters, + onSubmit, + handleCancel, + id, + filterSimilarLogsState, + ...initialFormValues +}) => { + const logsViewerContext = useContext(LogsViewerContext); + + useEffect(() => { + if (logsViewerContext.filter) { + setFieldValue(dialogFields.RegExpField.name, logsViewerContext.filter); + logsViewerContext.setFilter(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logsViewerContext.filter]); + + const formFields = ['LabelField', 'RegExpField']; + const constraints = { + LabelField: { + max: 25, + labels: filters, + labelId: id + } + }; + const validationSchema = createValidationSchema(formFields, constraints); + const { + values, + handleChange, + withValidation, + errors, + setFieldValue + } = useFormData(initialFormValues, { + initialSchema: validationSchema, + onChange: true + }); + + return ( +
+ + + + + ); +}; + +export default FilterForm; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/index.js new file mode 100644 index 000000000..00afb2d6c --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/index.js @@ -0,0 +1,157 @@ +import React, { useEffect, useContext } from 'react'; +import { useToggle } from '../../../../../../../hooks'; +import { v4 } from 'uuid'; +import { getFilters, saveFilters } from '../helpers'; +import QuarantineModal from '../../QuarantineModal'; +import ToggleIconButton from '../../ToggleIconButton'; + +import { + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Switch, + Tooltip +} from '@material-ui/core'; +import AdvancedIcon from '@material-ui/icons/FilterList'; +import AppDialog from '../../../../../../AppDialog'; +import AddItem from '../../../../../../AddItem'; +import EditFilter from './EditFilter'; +import DeleteItem from '../../../../../../DeleteItem'; +import FilterForm from './FilterForm'; +import { StyledExitButton } from './styled'; +import LogsViewerContext from '../../../context'; + +const AdvancedFiltersMenu = ({ widgetLocalStorage, quarantine }) => { + const [dialogOpened, openDialog, handleDialogClose] = useToggle(); + + const logsViewerContect = useContext(LogsViewerContext); + + useEffect(() => { + if (logsViewerContect.filter || logsViewerContect.quarantine) { + openDialog(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logsViewerContect.filter, logsViewerContect.quarantine]); + + const filters = getFilters(widgetLocalStorage); + + const addFilter = values => { + saveFilters(widgetLocalStorage, [ + ...filters, + { id: `filter-${v4()}`, checked: true, ...values } + ]); + }; + + const editFilter = ({ id, values }) => { + saveFilters( + widgetLocalStorage, + filters.map(filter => { + if (filter.id === id) { + return { id, checked: true, ...values }; + } + return filter; + }) + ); + }; + + const deleteFilter = id => { + saveFilters( + widgetLocalStorage, + filters.filter(filter => filter.id !== id) + ); + }; + + const handleSwitch = id => + saveFilters( + widgetLocalStorage, + filters.map(filter => + filter.id === id ? { ...filter, checked: !filter.checked } : filter + ) + ); + + const renderListItems = ( + items, + name, + EditComponent, + editAction, + deleteAction + ) => + items.map(({ id, label, checked, regExp }) => ( + + + + + + + + + handleSwitch(id)} + color="secondary" + /> + + + + )); + + return ( + <> + + + + {renderListItems( + filters, + 'filter', + EditFilter, + editFilter, + deleteFilter + )} + + + + + + + exit + + + + ); +}; + +export default AdvancedFiltersMenu; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/styled.js new file mode 100644 index 000000000..5b42fa61d --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/AdvancedFiltersMenu/styled.js @@ -0,0 +1,11 @@ +import styled from '@emotion/styled/macro'; +import { Button } from '@material-ui/core'; +import CancelButton from '../../../../../../CancelButton'; + +export const StyledExitButton = styled(Button)` + margin-top: 12px; +`; + +export const StyledCancelButton = styled(CancelButton)` + margin-left: 20px; +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/helpers.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/helpers.js new file mode 100644 index 000000000..b9e186871 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/helpers.js @@ -0,0 +1,21 @@ +export const saveFilters = ( + { get: localStorage, set: setLocalStorage }, + filters +) => { + const newWidgetData = { ...localStorage(), regExpFilters: filters }; + setLocalStorage(newWidgetData); +}; + +export const getFilters = widgetLocalStorage => + widgetLocalStorage.get()?.regExpFilters || []; + +export const saveLevel = ( + { get: localStorage, set: setLocalStorage }, + level +) => { + const newWidgetData = { ...localStorage(), logsLevel: level }; + setLocalStorage(newWidgetData); +}; + +export const getLevel = widgetLocalStorage => + widgetLocalStorage.get()?.logsLevel || 'info'; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/index.js new file mode 100644 index 000000000..1388a074f --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/index.js @@ -0,0 +1,112 @@ +import React from 'react'; +import { getFilters, getLevel, saveFilters, saveLevel } from './helpers'; +import logLevels from '../../logLevels'; + +import { MenuItem, InputLabel, Tooltip } from '@material-ui/core'; +import { + ScrollableBox, + FiltersFormControl, + FiltersSelect, + LogLevelFormControl, + LogLevelSelect, + StyledChip, + FiltersWrapper +} from './styled'; +import AdvancedFiltersMenu from './AdvancedFiltersMenu'; + +const FilterPicker = ({ widgetLocalStorage, quarantine }) => { + const regExpFilters = getFilters(widgetLocalStorage); + const logLevel = getLevel(widgetLocalStorage); + + const handleSelection = selectedList => + saveFilters( + widgetLocalStorage, + regExpFilters.map(filter => ({ + ...filter, + checked: selectedList.map(({ id }) => id).includes(filter.id) + })) + ); + + const handleDelete = id => + saveFilters( + widgetLocalStorage, + regExpFilters.map(filter => + filter.id === id ? { ...filter, checked: !filter.checked } : filter + ) + ); + + const handleLevelSelection = level => saveLevel(widgetLocalStorage, level); + + return ( + <> + + Log level + handleLevelSelection(e.target.value)} + data-cy="log-level-menu" + > + {Object.keys(logLevels).map((key, index) => ( + + {key.toUpperCase()} + + ))} + + + + + + {regExpFilters.length > 0 ? `Filters` : `No filters defined`} + + filter.checked)} + onChange={e => handleSelection(e.target.value)} + renderValue={selected => ( + + {selected.map(({ id, label, regExp }) => ( + + handleDelete(id)} + onMouseDown={e => e.stopPropagation()} + data-cy="filters-chip" + /> + + ))} + + )} + > + {regExpFilters.map(filter => ( + + {filter.label} + + ))} + + + + + + ); +}; + +export default FilterPicker; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/styled.js new file mode 100644 index 000000000..070ab0a7e --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/FilterPicker/styled.js @@ -0,0 +1,62 @@ +import styled from '@emotion/styled/macro'; +import { Box, Select, Chip, FormControl } from '@material-ui/core'; + +const selectDefaultProps = { + size: 'small', + MenuProps: { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left' + }, + getContentAnchorEl: null + } +}; + +export const ScrollableBox = styled(Box)` + overflow-x: scroll; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +export const LogLevelFormControl = styled(FormControl)` + flex-grow: 1; + min-width: 6rem; +`; +export const LogLevelSelect = Select; +LogLevelSelect.defaultProps = selectDefaultProps; + +export const FiltersFormControl = styled(FormControl)` + width: 100%; +`; +export const FiltersSelect = styled(Select)` + width: 100%; + min-width: 10rem; + & .MuiBox-root { + max-height: 19px; + } +`; +FiltersSelect.defaultProps = selectDefaultProps; + +export const StyledChip = styled(Chip)` + margin-right: 0.25rem; + height: 18px; + position: relative; + top: -1px; + + & > svg { + margin-right: 2px; + } +`; +StyledChip.defaultProps = { + size: 'small' +}; + +export const FiltersWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: flex-end; + flex-grow: 8; +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/EditQFilter.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/EditQFilter.js new file mode 100644 index 000000000..0fdee5749 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/EditQFilter.js @@ -0,0 +1,46 @@ +import React from 'react'; + +import { useToggle } from '../../../../../../hooks'; + +import { IconButton, Tooltip } from '@material-ui/core'; +import { Build } from '@material-ui/icons'; +import AppDialog from '../../../../../AppDialog'; +import QuarantineForm from './QuarantineForm'; + +const EditQFilter = ({ id, filters, editAction }) => { + const [dialogOpened, openDialog, handleDialogClose] = useToggle(); + const filterData = filters.find(filter => filter.id === id); + + const handleSubmit = values => { + editAction({ id, values }); + handleDialogClose(); + }; + + return ( + <> + + + + + + + {filterData && ( + + )} + + + ); +}; + +export default EditQFilter; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/QuarantineForm.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/QuarantineForm.js new file mode 100644 index 000000000..cfeca0676 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/QuarantineForm.js @@ -0,0 +1,102 @@ +import React, { useEffect, useContext } from 'react'; +import { createValidationSchema } from '../../../../../validation'; +import { useFormData } from '../../../../../../hooks'; +import DynamicForm from '../../../../../DynamicForm'; +import { Button, Tooltip, IconButton } from '@material-ui/core'; +import InfoIcon from '@material-ui/icons/Info'; +import { StyledHorizontalContainer, StyledButtonContainer } from './styled'; +import dialogFields from '../../../../dialogFields'; +import LogsViewerContext from '../../context'; +import { URL } from '../../../../../../constants'; +import CancelButton from '../../../../../CancelButton'; + +const QuarantineForm = ({ + filters, + onSubmit, + handleCancel, + id, + ...initialFormValues +}) => { + const logsViewerContext = useContext(LogsViewerContext); + + useEffect(() => { + if (logsViewerContext.quarantine) { + setFieldValue( + dialogFields.RegExpField.name, + logsViewerContext.quarantine + ); + logsViewerContext.setQuarantine(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logsViewerContext.quarantine]); + + const formFields = [ + 'LabelField', + 'RegExpField', + 'ReasonField', + 'EndTimestampField' + ]; + const constraints = { + LabelField: { + max: 25, + labels: filters, + labelId: id + } + }; + + const validationSchema = createValidationSchema(formFields, constraints); + const { + values, + handleChange, + withValidation, + errors, + setFieldValue + } = useFormData(initialFormValues, { + initialSchema: validationSchema, + onChange: true + }); + + return ( +
+ + + + + + +

+ Logs, any fields of which will be matched by the regular expression, + will not be stored in the database or displayed. +

+
+ + + + +

In order to see the changes, you have to refresh the page.

+
+ + ); +}; + +export default QuarantineForm; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/index.js new file mode 100644 index 000000000..c1ac1fcf7 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/index.js @@ -0,0 +1,179 @@ +import React, { useEffect, useContext } from 'react'; +import { useSelector } from 'react-redux'; +import moment from 'moment-timezone'; +import { v4 } from 'uuid'; +import LogsViewerContext from '../../context'; +import { MILLIS_IN_SECOND } from '../../../../../../constants'; +import { + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Switch, + Tooltip +} from '@material-ui/core'; +import { StyledButton } from './styled'; +import { getIsAuthenticated } from '../../../../../../selectors'; +import { useToggle } from '../../../../../../hooks'; +import { postWidgetContentUpdate } from '../../../../../../utils/fetch'; +import AppDialog from '../../../../../AppDialog'; +import AddItem from '../../../../../AddItem'; +import QuarantineForm from './QuarantineForm'; +import EditQFilter from './EditQFilter'; +import DeleteItem from '../../../../../DeleteItem'; + +const QuarantineModal = ({ quarantine }) => { + const isAuthenticated = useSelector(getIsAuthenticated); + const [dialogOpened, openDialog, handleDialogClose] = useToggle(); + + const logsViewerContext = useContext(LogsViewerContext); + + useEffect(() => { + if (logsViewerContext.quarantine) { + openDialog(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logsViewerContext.quarantine]); + + const isChecked = (checked, endTimestamp) => { + if (endTimestamp) { + const inFuture = + moment.utc(endTimestamp * MILLIS_IN_SECOND).local() > moment(); + return checked && inFuture; + } + return checked; + }; + + const toggleChecked = rule => { + const endTimestamp = + Number.isInteger(rule.endTimestamp) && + moment.utc(rule.endTimestamp * MILLIS_IN_SECOND).local(); + const shouldSkipEndTimestamp = endTimestamp && endTimestamp <= moment(); + if (shouldSkipEndTimestamp) { + return { ...rule, checked: true, endTimestamp: null }; + } else { + return { ...rule, checked: !rule.checked }; + } + }; + + const handleQuarantineClick = event => { + event.stopPropagation(); + openDialog(); + }; + + const addFilter = values => { + postWidgetContentUpdate({ + id: logsViewerContext.wid, + quarantineRules: [...quarantine, { id: v4(), checked: true, ...values }] + }); + }; + + const editFilter = ({ id, values }) => { + postWidgetContentUpdate({ + id: logsViewerContext.wid, + quarantineRules: quarantine.map(filter => { + if (filter.id === id) { + return { id, ...values }; + } + return filter; + }) + }); + }; + + const handleSwitchChange = id => { + postWidgetContentUpdate({ + id: logsViewerContext.wid, + quarantineRules: quarantine.map(rule => + rule.id === id ? toggleChecked(rule) : rule + ) + }); + }; + + const deleteAction = id => { + postWidgetContentUpdate({ + id: logsViewerContext.wid, + quarantineRules: quarantine.filter(quarantine => quarantine.id !== id) + }); + }; + + if (!isAuthenticated) { + return null; + } + + const renderListItems = ( + items, + name, + EditComponent, + editAction, + deleteAction + ) => + items.map(({ id, label, checked, reasonField, endTimestamp }) => ( + + + + + + + + handleSwitchChange(id)} + color="secondary" + > + + + )); + + return ( + <> + + Quarantine + + + + {renderListItems( + quarantine, + 'quarantine', + EditQFilter, + editFilter, + deleteAction + )} + + + + + + Exit + + + + ); +}; + +export default QuarantineModal; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/styled.js new file mode 100644 index 000000000..95a80e5e7 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/QuarantineModal/styled.js @@ -0,0 +1,31 @@ +import styled from '@emotion/styled/macro'; + +import { Button } from '@material-ui/core'; + +export const StyledButton = styled(Button)` + margin-top: 12px; +`; + +export const StyledHorizontalContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +export const StyledButtonContainer = styled.div` + display: grid; + grid-template: auto / auto auto 1fr; + align-items: center; + gap: 20px; + + p { + margin: 0; + } + + @media (max-width: 764px) { + grid-template: auto auto / repeat(2, min-content) auto; + p { + grid-column: 1 / 4; + } + } +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/SearchInput/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/SearchInput/index.js new file mode 100644 index 000000000..d5ccc777c --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/SearchInput/index.js @@ -0,0 +1,56 @@ +import React, { useState, useEffect } from 'react'; +import { func, number } from 'prop-types'; +import { useDebounce } from '../../../../../../hooks'; + +import { Wrapper, CustomIconButton, StyledTextField } from './styled'; +import SearchIcon from '@material-ui/icons/Search'; +import CloseIcon from '@material-ui/icons/Close'; + +const SearchInput = ({ setSearchFilter, debounce, minLetters }) => { + const [searchBoxValue, setSearchBoxValue] = useState(''); + + const valueToSearch = useDebounce(searchBoxValue, debounce); + const enoughLetters = valueToSearch.length >= minLetters; + useEffect(() => setSearchFilter(enoughLetters ? valueToSearch : ''), [ + valueToSearch, + setSearchFilter, + enoughLetters + ]); + + const handleChange = e => setSearchBoxValue(e.target.value); + const clearSearch = () => { + setSearchBoxValue(''); + setSearchFilter(''); + }; + + return ( + + + + {enoughLetters ? ( + + ) : ( + + )} + + + ); +}; + +SearchInput.propTypes = { + setSearchFilter: func.isRequired, + debounce: number, + minLetters: number +}; + +SearchInput.defaultProps = { + debounce: 500, + minLetters: 3 +}; + +export default SearchInput; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/SearchInput/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/SearchInput/styled.js new file mode 100644 index 000000000..2fb02ebdd --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/SearchInput/styled.js @@ -0,0 +1,28 @@ +import styled from '@emotion/styled/macro'; +import { IconButton, TextField } from '@material-ui/core'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: row; + position: relative; + flex-grow: 4; +`; + +export const CustomIconButton = styled(IconButton)` + position: absolute; + bottom: 0; + right: 0; +`; +CustomIconButton.defaultProps = { + variant: 'outlined', + size: 'small' +}; + +export const StyledTextField = styled(TextField)` + min-width: 5rem; + width: 100%; + + & input { + margin-right: 1.8rem; + } +`; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/ToggleIconButton.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/ToggleIconButton.js new file mode 100644 index 000000000..c75e08042 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/ToggleIconButton.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { string, elementType, bool } from 'prop-types'; +import { Tooltip } from '@material-ui/core'; +import { StyledIconButton } from './styled'; + +const ToggleIconButton = ({ tooltip, Icon, enabled, ...props }) => { + return ( + + + + + + ); +}; + +ToggleIconButton.propTypes = { + tooltip: string, + Icon: elementType.isRequired, + enabled: bool +}; + +ToggleIconButton.defaultProps = { + tooltip: '', + enabled: false +}; + +export default ToggleIconButton; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/index.js new file mode 100644 index 000000000..29a34a8ca --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/index.js @@ -0,0 +1,50 @@ +import React from 'react'; + +import ToggleIconButton from './ToggleIconButton'; +import { Wrapper } from './styled'; +import SearchInput from './SearchInput'; +import DateRangePicker from './DateRangePicker'; +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; +import SaveIcon from '@material-ui/icons/Save'; +import FilterPicker from './FilterPicker'; + +const Toolbar = ({ + quarantine, + widgetLocalStorage, + setSearchFilter, + shouldFollowLogs, + handleFollowChange, + lastLog, + onSaveLogs +}) => { + return ( + + + + + handleFollowChange(!shouldFollowLogs)} + enabled={shouldFollowLogs} + Icon={ArrowDownwardIcon} + /> + + + ); +}; + +export default Toolbar; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/styled.js new file mode 100644 index 000000000..bf8defe95 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/Toolbar/styled.js @@ -0,0 +1,29 @@ +import styled from '@emotion/styled/macro'; +import { IconButton } from '@material-ui/core'; +import { COLORS } from '../../../../../constants'; + +export const Wrapper = styled.div` + width: 100%; + overflow-x: scroll; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-end; + gap: 1em; + overflow-x: hidden; + padding: 0 10px 1.1rem 10px; +} +`; + +export const StyledIconButton = styled(IconButton)` + ${props => + props.enabled === true.toString() + ? ` + color: ${COLORS.BLUE}; + background-color: ${COLORS.LIGHT_SHADE} + ` + : ''} +`; +StyledIconButton.defaultProps = { + size: 'small' +}; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/context.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/context.js new file mode 100644 index 000000000..82d7d738f --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/context.js @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +const logsViewerContext = createContext(); + +export default logsViewerContext; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/helpers.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/helpers.js new file mode 100644 index 000000000..381ab8e67 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/helpers.js @@ -0,0 +1,31 @@ +export const joinLogs = (currentLogs, newLogs, logLines) => { + const joined = currentLogs.slice(); + newLogs.forEach(newLog => { + if (!joined.some(log => log._id === newLog._id)) { + joined.push(newLog); + } + }); + return joined.slice(-logLines); +}; + +const saveFile = blob => { + const a = document.createElement('a'); + const today = new Date(); + const options = { + weekday: 'long', + year: 'numeric', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }; + a.download = today.toLocaleDateString('en-US', options) + '.txt'; + a.href = URL.createObjectURL(blob); + a.addEventListener('click', e => { + setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000); + }); + a.click(); +}; + +export const saveLogsToFile = logs => + saveFile(new Blob([JSON.stringify(logs)])); diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/index.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/index.js new file mode 100644 index 000000000..8b9cabcc6 --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/index.js @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import { number, string } from 'prop-types'; +import { useLocalStorage } from '../../../../hooks'; +import { joinLogs, saveLogsToFile } from './helpers'; +import LogsViewerContext from './context'; +import { getInitialLogs } from '../../../../utils/fetch'; +import { getFilters, getLevel } from './Toolbar/FilterPicker/helpers'; +import { getDateSpan } from './Toolbar/DateRangePicker/helpers'; +import { + filterByRegExp, + filterByDateSpan, + filterByLevel +} from './LogList/helpers'; + +import Toolbar from './Toolbar'; +import LogList from './LogList'; +import { Container } from './styled'; + +const LogViewerWidget = ({ id }) => { + const widgetData = useSelector( + ({ widgets }) => widgets.widgetsById[id], + shallowEqual + ); + + const [widgetLocalStorageData, setWidgetLocalStorage] = useLocalStorage(id); + const widgetLocalStorage = { + get: () => widgetLocalStorageData, + set: setWidgetLocalStorage + }; + + const [searchFilter, setSearchFilter] = useState(''); + const [shouldFollowLogs, setFollow] = useState(true); + + useEffect(() => { + getInitialLogs(id).then(logs => setStoredLogs(logs)); + }, [id]); + + const newLogs = widgetData.content?.logs || []; + const logLinesField = widgetData.logLinesField; + const template = widgetData.content?.variableFields; + const quarantine = widgetData.content?.quarantineRules || []; + + const [storedLogs, setStoredLogs] = useState([]); + + useEffect(() => { + setStoredLogs(joinLogs(storedLogs, newLogs, logLinesField)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [widgetData.content?.logs]); + + const [filterSimilarLogs, setFilterSimilarLogs] = useState(null); + const [quarantineSimilarLogs, setQuarantineSimilarLogs] = useState(null); + + const filters = getFilters(widgetLocalStorage); + const level = getLevel(widgetLocalStorage); + const dateSpan = getDateSpan(widgetLocalStorage); + + const filteredLogs = storedLogs + ?.filter(log => filterByLevel(log, level)) + .filter(log => filterByDateSpan(log, dateSpan)) + .filter(log => filterByRegExp(log, filters)); + + return ( + + + 0 && storedLogs[storedLogs.length - 1]} + onSaveLogs={() => saveLogsToFile(filteredLogs)} + /> + + + + ); +}; + +LogViewerWidget.propTypes = { + endpoint: string, + schedulePeriod: number, + path: string, + logLinesField: number, + logFileSizeField: number, + logRecordExpirationField: number +}; + +LogViewerWidget.defaultProps = { + endpoint: '', + schedulePeriod: 60, + path: '', + logLinesField: 1000, + logFileSizeField: 50, + logRecordExpirationField: 5 +}; + +export default LogViewerWidget; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/logLevels.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/logLevels.js new file mode 100644 index 000000000..ed7e7183e --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/logLevels.js @@ -0,0 +1,10 @@ +import { COLORS } from '../../../../constants'; + +const logLevels = { + debug: { level: 10, color: COLORS.WHITE }, + info: { level: 20, color: COLORS.WHITE }, + warning: { level: 30, color: COLORS.YELLOW }, + error: { level: 40, color: COLORS.RED } +}; + +export default logLevels; diff --git a/cogboard-webapp/src/components/widgets/types/LogViewerWidget/styled.js b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/styled.js new file mode 100644 index 000000000..cea6151ba --- /dev/null +++ b/cogboard-webapp/src/components/widgets/types/LogViewerWidget/styled.js @@ -0,0 +1,10 @@ +import styled from '@emotion/styled/macro'; + +export const Container = styled.div` + position: relative; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + min-width: 54rem; +`; diff --git a/cogboard-webapp/src/components/widgets/types/TextWidget/index.js b/cogboard-webapp/src/components/widgets/types/TextWidget/index.js index 53b229b78..032e2d52b 100644 --- a/cogboard-webapp/src/components/widgets/types/TextWidget/index.js +++ b/cogboard-webapp/src/components/widgets/types/TextWidget/index.js @@ -1,86 +1,86 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { useSize } from 'react-hook-size'; -import { bool, string } from 'prop-types'; - -import { - TypographyVariant, - CenterWrapper, - StyledPre, - RotatedStyledPre, - OverflowingText, - SingleLineText, - SetWidth -} from './styled'; - -export const ModifiedWidth = (component, height) => { - if (height) { - return SetWidth(component, height); - } - - return component; -}; - -const TruncatedText = ({ - isVertical, - parentDimensions, - children, - singleLine -}) => { - let TruncatedPre = null; - - if (isVertical && parentDimensions !== null) { - const { height } = parentDimensions; - const ModifiedPre = ModifiedWidth(RotatedStyledPre, height); - const VerticalText = height ? ModifiedPre : RotatedStyledPre; - - TruncatedPre = OverflowingText(VerticalText); - } else if (singleLine) { - TruncatedPre = SingleLineText(StyledPre); - } else { - TruncatedPre = OverflowingText(StyledPre); - } - - return {children}; -}; - -const TextWidget = ({ text, textSize, isVertical, singleLine }) => { - const targetRef = useRef(null); - const centerWrapperDimensions = useSize(targetRef); - const [dimensions, setDimensions] = useState(null); - - useEffect(() => { - if (centerWrapperDimensions.height) { - setDimensions(centerWrapperDimensions); - } - }, [centerWrapperDimensions]); - - return ( - - - - {text} - - - - ); -}; - -TextWidget.propTypes = { - text: string, - textSize: string, - isVertical: bool, - singleLine: bool -}; - -TextWidget.defaultProps = { - text: '', - textSize: '', - isVertical: false, - singleLine: false -}; - -export default TextWidget; +import React, { useState, useRef, useEffect } from 'react'; +import { useSize } from 'react-hook-size'; +import { bool, string } from 'prop-types'; + +import { + TypographyVariant, + CenterWrapper, + StyledPre, + RotatedStyledPre, + OverflowingText, + SingleLineText, + SetWidth +} from './styled'; + +export const ModifiedWidth = (component, height) => { + if (height) { + return SetWidth(component, height); + } + + return component; +}; + +const TruncatedText = ({ + isVertical, + parentDimensions, + children, + singleLine +}) => { + let TruncatedPre = null; + + if (isVertical && parentDimensions !== null) { + const { height } = parentDimensions; + const ModifiedPre = ModifiedWidth(RotatedStyledPre, height); + const VerticalText = height ? ModifiedPre : RotatedStyledPre; + + TruncatedPre = OverflowingText(VerticalText); + } else if (singleLine) { + TruncatedPre = SingleLineText(StyledPre); + } else { + TruncatedPre = OverflowingText(StyledPre); + } + + return {children}; +}; + +const TextWidget = ({ text, textSize, isVertical, singleLine }) => { + const targetRef = useRef(null); + const centerWrapperDimensions = useSize(targetRef); + const [dimensions, setDimensions] = useState(null); + + useEffect(() => { + if (centerWrapperDimensions.height) { + setDimensions(centerWrapperDimensions); + } + }, [centerWrapperDimensions]); + + return ( + + + + {text} + + + + ); +}; + +TextWidget.propTypes = { + text: string, + textSize: string, + isVertical: bool, + singleLine: bool +}; + +TextWidget.defaultProps = { + text: '', + textSize: '', + isVertical: false, + singleLine: false +}; + +export default TextWidget; diff --git a/cogboard-webapp/src/components/widgets/types/ZabbixWidget/index.js b/cogboard-webapp/src/components/widgets/types/ZabbixWidget/index.js index 106b2a7a7..57f6b0428 100644 --- a/cogboard-webapp/src/components/widgets/types/ZabbixWidget/index.js +++ b/cogboard-webapp/src/components/widgets/types/ZabbixWidget/index.js @@ -1,123 +1,129 @@ -import React from 'react'; -import { shallowEqual, useSelector } from 'react-redux'; -import SemiCircleProgress from '../../../SemiProgressBar'; -import { - StyledArrowDown, - StyledArrowUp, - StyledMetricName, - StyledNumericValue, - StyledZabbixWrapper, - StyledNumericValueWithIcon -} from './styled'; -import { - COLORS, - ZABBIX_METRICS, - ZABBIX_METRICS_WITH_MAX_VALUE, - ZABBIX_METRICS_WITH_PROGRESS -} from '../../../../constants'; - - -const progressBarWidth = { - column1: { - diameter: 150 - }, - column2: { - diameter: 200 - }, - other: { - diameter: 220 - } -}; - -const ZabbixWidget = ({ id, lastvalue, history }) => { - const widgetData = useSelector( - ({ widgets }) => widgets.widgetsById[id], - shallowEqual - ); - const upTimeMetricName = 'system.uptime'; - const widgetConfig = widgetData.config; - const widgetZabbixMetric = widgetData.selectedZabbixMetric; - const maxValue = widgetData.maxValue; - - const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes(widgetZabbixMetric); - const checkMetricHasMaxValue = ZABBIX_METRICS_WITH_MAX_VALUE.includes(widgetZabbixMetric); - - const setProgressSize = () => { - const widgetColumns = widgetConfig.columns; - return progressBarWidth[`column${widgetColumns}`] - ? progressBarWidth[`column${widgetColumns}`].diameter - : progressBarWidth.other.diameter; - }; - - const calculatePercentageValue = () => { - if (!lastvalue) return 0; - if (!checkMetricHasMaxValue) return parseInt(lastvalue, 10); - - return Math.round((100 * lastvalue) / (maxValue * Math.pow(10, 9))); - }; - - const convertMetricTitle = () => { - if (!widgetZabbixMetric) return ''; - - return ZABBIX_METRICS.find(item => item.value === widgetZabbixMetric).display; - }; - - const convertToGigaBytes = () => { - if (!lastvalue) return 0; - return Math.round(lastvalue / Math.pow(10, 9)); - }; - - const secondsToTime = value => { - const days = Math.floor(value / 3600 / 24).toString(), - hours = Math.floor((value / 3600) % 24).toString(), - minutes = Math.floor((value % 3600) / 60).toString(); - - return days + 'd:' + hours + 'h:' + minutes + 'm'; - }; - - const renderNoProgressContent = () => { - if (!lastvalue) return; - - const historyValues = Object.values(history); - const historyCurrentValue = historyValues[historyValues.length - 1]; - const historyPrevValue = historyValues[historyValues.length - 2]; - const value = - widgetZabbixMetric === upTimeMetricName - ? secondsToTime(lastvalue) - : parseInt(lastvalue, 10); - - return ( - <> - { - widgetZabbixMetric === upTimeMetricName ? ( - {value} - ) : ( - - {value} - { historyCurrentValue > historyPrevValue ? : } - - ) - } - - ); - }; - - return ( - - {checkMetricHasProgress ? ( - - ) : ( - renderNoProgressContent() - )} - {convertMetricTitle()} - - ); -}; - -export default ZabbixWidget; +import React from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import SemiCircleProgress from '../../../SemiProgressBar'; +import { + StyledArrowDown, + StyledArrowUp, + StyledMetricName, + StyledNumericValue, + StyledZabbixWrapper, + StyledNumericValueWithIcon +} from './styled'; +import { + COLORS, + ZABBIX_METRICS, + ZABBIX_METRICS_WITH_MAX_VALUE, + ZABBIX_METRICS_WITH_PROGRESS +} from '../../../../constants'; + +const progressBarWidth = { + column1: { + diameter: 150 + }, + column2: { + diameter: 200 + }, + other: { + diameter: 220 + } +}; + +const ZabbixWidget = ({ id, lastvalue, history }) => { + const widgetData = useSelector( + ({ widgets }) => widgets.widgetsById[id], + shallowEqual + ); + const upTimeMetricName = 'system.uptime'; + const widgetConfig = widgetData.config; + const widgetZabbixMetric = widgetData.selectedZabbixMetric; + const maxValue = widgetData.maxValue; + + const checkMetricHasProgress = ZABBIX_METRICS_WITH_PROGRESS.includes( + widgetZabbixMetric + ); + const checkMetricHasMaxValue = ZABBIX_METRICS_WITH_MAX_VALUE.includes( + widgetZabbixMetric + ); + + const setProgressSize = () => { + const widgetColumns = widgetConfig.columns; + return progressBarWidth[`column${widgetColumns}`] + ? progressBarWidth[`column${widgetColumns}`].diameter + : progressBarWidth.other.diameter; + }; + + const calculatePercentageValue = () => { + if (!lastvalue) return 0; + if (!checkMetricHasMaxValue) return parseInt(lastvalue, 10); + + return Math.round((100 * lastvalue) / (maxValue * Math.pow(10, 9))); + }; + + const convertMetricTitle = () => { + if (!widgetZabbixMetric) return ''; + + return ZABBIX_METRICS.find(item => item.value === widgetZabbixMetric) + .display; + }; + + const convertToGigaBytes = () => { + if (!lastvalue) return 0; + return Math.round(lastvalue / Math.pow(10, 9)); + }; + + const secondsToTime = value => { + const days = Math.floor(value / 3600 / 24).toString(), + hours = Math.floor((value / 3600) % 24).toString(), + minutes = Math.floor((value % 3600) / 60).toString(); + + return days + 'd:' + hours + 'h:' + minutes + 'm'; + }; + + const renderNoProgressContent = () => { + if (!lastvalue) return; + + const historyValues = Object.values(history); + const historyCurrentValue = historyValues[historyValues.length - 1]; + const historyPrevValue = historyValues[historyValues.length - 2]; + const value = + widgetZabbixMetric === upTimeMetricName + ? secondsToTime(lastvalue) + : parseInt(lastvalue, 10); + + return ( + <> + {widgetZabbixMetric === upTimeMetricName ? ( + {value} + ) : ( + + {value} + {historyCurrentValue > historyPrevValue ? ( + + ) : ( + + )} + + )} + + ); + }; + + return ( + + {checkMetricHasProgress ? ( + + ) : ( + renderNoProgressContent() + )} + {convertMetricTitle()} + + ); +}; + +export default ZabbixWidget; diff --git a/cogboard-webapp/src/components/widgets/types/ZabbixWidget/styled.js b/cogboard-webapp/src/components/widgets/types/ZabbixWidget/styled.js index d0365c42c..35333ecf9 100644 --- a/cogboard-webapp/src/components/widgets/types/ZabbixWidget/styled.js +++ b/cogboard-webapp/src/components/widgets/types/ZabbixWidget/styled.js @@ -1,40 +1,40 @@ -import styled from '@emotion/styled/macro'; - -import { Typography } from '@material-ui/core'; -import { ArrowDownward, ArrowUpward } from '@material-ui/icons'; -import { COLORS } from '../../../../constants'; - -export const StyledArrowDown = styled(ArrowDownward)` - color: ${COLORS.RED}; -`; - -export const StyledArrowUp = styled(ArrowUpward)` - color: ${COLORS.GREEN_DEFAULT};; -`; - -export const StyledMetricName = styled(Typography)` - font-size: 0.775rem; - font-weight: 600; - text-align: center; -`; - -export const StyledNumericValue = styled(Typography)` - margin-bottom: 42px; - text-align: center; -`; - -export const StyledNumericValueWithIcon = styled.div` - align-items: center; - display: flex; - font-size: 16px; - justify-content: center; - margin-bottom: 42px; -`; - -export const StyledZabbixWrapper = styled.div` - display: flex; - flex-direction: column; - flex: 1; - justify-content: flex-end; - margin-bottom: 12px; -`; +import styled from '@emotion/styled/macro'; + +import { Typography } from '@material-ui/core'; +import { ArrowDownward, ArrowUpward } from '@material-ui/icons'; +import { COLORS } from '../../../../constants'; + +export const StyledArrowDown = styled(ArrowDownward)` + color: ${COLORS.RED}; +`; + +export const StyledArrowUp = styled(ArrowUpward)` + color: ${COLORS.GREEN_DEFAULT}; +`; + +export const StyledMetricName = styled(Typography)` + font-size: 0.775rem; + font-weight: 600; + text-align: center; +`; + +export const StyledNumericValue = styled(Typography)` + margin-bottom: 42px; + text-align: center; +`; + +export const StyledNumericValueWithIcon = styled.div` + align-items: center; + display: flex; + font-size: 16px; + justify-content: center; + margin-bottom: 42px; +`; + +export const StyledZabbixWrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; + justify-content: flex-end; + margin-bottom: 12px; +`; diff --git a/cogboard-webapp/src/constants/index.js b/cogboard-webapp/src/constants/index.js index 71da9aa5f..6ac457451 100644 --- a/cogboard-webapp/src/constants/index.js +++ b/cogboard-webapp/src/constants/index.js @@ -15,7 +15,9 @@ export const COLORS = { LIGHT_BLUE: '#bbdefb', BLUE: '#198cbd', PURPLE: '#26243e', - YELLOW: '#FECD00' + YELLOW: '#FECD00', + LIGHT_SHADE: 'rgba(255,255,255, 0.1)', + DARK_SHADE: 'rgba(0,0,0, 0.15)' }; export const URL = { @@ -30,7 +32,10 @@ export const URL = { CREDENTIALS_ENDPOINT: '/api/credentials', UPDATE_USER_SETTINGS: '/api/user/update', UPDATE_INFO: 'https://github.com/wttech/cogboard/wiki#update', - CREDENTIAL_INFO: 'https://github.com/wttech/cogboard/wiki#credentials' + CREDENTIAL_INFO: 'https://github.com/wttech/cogboard/wiki#credentials', + LOGS_ENDPOINT: '/api/logs', + LOGSVIEWER_QUARANTINE: + 'https://github.com/wttech/cogboard/wiki/4.-Widgets#LogsViewer' }; export const COLUMN_MULTIPLIER = 2; export const ROW_MULTIPLIER = 2; @@ -303,7 +308,9 @@ export const validationMessages = { INVALID_PUBLIC_URL: () => 'Invalid Public URL', FIELD_MIN_ITEMS: () => 'This field must have at least 1 item.', UNIQUE_FIELD: () => 'This field must be unique.', - PASSWORD_MATCH: () => 'Password must match.' + PASSWORD_MATCH: () => 'Password must match.', + SSH_KEY_BEGIN: () => 'The key must begin with "-----BEGIN PRIVATE KEY-----"', + SSH_KEY_END: () => 'The key must end with "-----END PRIVATE KEY-----"' }; export const NOTIFICATIONS = { @@ -340,3 +347,6 @@ export const NOTIFICATIONS = { duration: 3000 }) }; + +export const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'; +export const MILLIS_IN_SECOND = 1000; diff --git a/cogboard-webapp/src/hooks/index.js b/cogboard-webapp/src/hooks/index.js index cb3dd5f15..e260e2c9c 100644 --- a/cogboard-webapp/src/hooks/index.js +++ b/cogboard-webapp/src/hooks/index.js @@ -93,7 +93,8 @@ export const useFormData = (data, config = {}) => { withValidation, errors, validationSchema, - setValidationSchema + setValidationSchema, + setFieldValue }; }; @@ -136,3 +137,24 @@ export function useEventListener(eventName, handler, element = window) { }; }, [eventName, element]); } + +export const useLocalStorage = key => { + const stringValue = window.localStorage.getItem(key); + const objectValue = stringValue ? JSON.parse(stringValue) : null; + const [data, setStoredValue] = useState(objectValue); + + const setData = data => { + window.localStorage.setItem(key, JSON.stringify(data)); + setStoredValue(data); + }; + return [data, setData]; +}; + +export const useDebounce = (value, delay) => { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const timeoutRef = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timeoutRef); + }, [value, delay]); + return debouncedValue; +}; diff --git a/cogboard-webapp/src/reducers/widgets/index.js b/cogboard-webapp/src/reducers/widgets/index.js index 06d145c87..5b5ba2544 100644 --- a/cogboard-webapp/src/reducers/widgets/index.js +++ b/cogboard-webapp/src/reducers/widgets/index.js @@ -2,10 +2,12 @@ import { combineReducers } from 'redux'; import widgetsById from './widgetsById'; import widgetTypes from './widgetTypes'; +import logsViewersCache from './logsViewers'; const widgets = combineReducers({ widgetsById, - widgetTypes + widgetTypes, + logsViewersCache }); export default widgets; diff --git a/cogboard-webapp/src/reducers/widgets/logsViewers.js b/cogboard-webapp/src/reducers/widgets/logsViewers.js new file mode 100644 index 000000000..7f12d00c1 --- /dev/null +++ b/cogboard-webapp/src/reducers/widgets/logsViewers.js @@ -0,0 +1,38 @@ +import { TOGGLE_LOGS_VIEWER_LOG } from '../../actions/types'; + +const toggleLogsViewerLog = (state, { payload }) => { + const { wid, logid } = payload; + const expandedLogs = state[wid]?.expandedLogs || []; + + const removeFromList = (index, list) => [ + ...list.slice(0, index), + ...list.slice(index + 1, list.length) + ]; + + const index = expandedLogs.indexOf(logid); + const logCollapsed = index === -1; + const newExpandedLogs = logCollapsed + ? [...expandedLogs, logid] + : removeFromList(index, expandedLogs); + + return { + ...state, + [wid]: { + ...state[wid], + expandedLogs: newExpandedLogs + } + }; +}; + +const logsViewers = (state = {}, action) => { + const { type } = action; + + switch (type) { + case TOGGLE_LOGS_VIEWER_LOG: + return toggleLogsViewerLog(state, action); + default: + return state; + } +}; + +export default logsViewers; diff --git a/cogboard-webapp/src/theme.js b/cogboard-webapp/src/theme.js index de1b6498d..acd01ff55 100644 --- a/cogboard-webapp/src/theme.js +++ b/cogboard-webapp/src/theme.js @@ -25,6 +25,12 @@ export const theme = createMuiTheme({ marginTop: 0, marginRight: 0 } + }, + // Datepicker dialog + MuiDialogActions: { + root: { + backgroundColor: COLORS.WHITE + } } }, palette: { diff --git a/cogboard-webapp/src/utils/fetch.js b/cogboard-webapp/src/utils/fetch.js index 07ad2452a..1a6b36bcf 100644 --- a/cogboard-webapp/src/utils/fetch.js +++ b/cogboard-webapp/src/utils/fetch.js @@ -1,4 +1,5 @@ import { navigate } from '@reach/router'; +import { URL } from '../constants'; export const checkResponseStatus = response => { const { status, statusText } = response; @@ -26,3 +27,7 @@ export const postWidgetContentUpdate = (data = {}) => { .then(checkResponseStatus) .then(response => response.json()); }; + +export const getInitialLogs = id => { + return fetch(`${URL.LOGS_ENDPOINT}/${id}`).then(response => response.json()); +}; diff --git a/functional/cypress-tests/README.md b/functional/cypress-tests/README.md index b7412c431..5ea559e85 100644 --- a/functional/cypress-tests/README.md +++ b/functional/cypress-tests/README.md @@ -1,103 +1,103 @@ -# Cypress Automated Functional Tests - -## :construction: Work in progress :construction: - -Tests in this part of the repository are testing UI of the CogBoard. Get to know helper functions available right now (and update them if needed). - -## Running tests - -### CLI - -Quick use (i.e. before push): - -`./gradlew functionalTests` - will launch all specs, with local env config file. - -To override env add `-DcypressEnv=envName` system property. Which will run cypress config named `envName.json` under `functional/cypress-tests/cypress/config` directory. You must make sure that you created such a config in a first place. - -To customize config and specs to be launched, you have to run: - -`npx cypress run [--config-file path/to/config.json]`1 `[--spec path/to/spec.js]`2 from the `functional/cypress-tests` directory - -1 - provide custom config file path, by default it will use `functional/cypress-tests/cypress/config/local.json`. - -2 - provide spec file(s) to be launched. By default all specs are run. - -### GUI - -1. Go to `functional/cypress-tests` directory -1. Install Cypress `npm install` (run only once) -1. Open Cypress tools by executing `npx cypress open` - -## Contribution guide - -All cypress related commits should be done on branches starting with `automation/` - -### Tasks to do - -... - -### Coding conventions - -All tests should be written according to [Cypress.io Best Practices guide](https://docs.cypress.io/guides/references/best-practices.html 'Best Practices | Cypress Documentation'). Read it before starting to contribute. - -Additional project specific conventions: - -- For actions that have been already covered in other tests write `cy.request(...)` helper command to bypass UI and speed up the test execution time - - Good example would be the Widget tests. Dashboard creation and dashboard removal steps are performed in each of those tests and take around 2.5s. If we have used API calls instead we would be lookng at `2.5s * numOfWidgets` time reduction. -- Keep test data in separate files. i.e. `../fixtures/Widgets.js` -- No test case dependencies. Each test's expected initial state should be either prepared manually or by `cy.request(...)` call before test starts. -- Actions that impact on other specs (such as saving) should be kept in the same spec. -- Repetitive tests should be written in iterative manner: i.e. `../integration/widgets.js` or FE validation tests in `../integration/dashboards.js` -- ... - -## Available helpers - -Use helpers below to minimize specfiles size. Keep in mind that some of those helpers require certain state of app to work correctly, make sure you learn their code. - -### General - -`cy.saveState()` - Saves current state of the Dashboard - -### User - -`cy.login()` - Log in with credentials specified in configuration file. - _Currently `../cypress.json`_ - -`cy.logout()` - Log out of the application - -`getAuthenticationToken()` - Returns authentication token for username: admin - -`loginWithToken()` - Log in as admin - -### Dashboard - -`cy.openDrawer()` - Open the Dashboard Drawer - -`cy.closeDrawer()` - Close the Dashboard Drawer. - -`cy.chooseDashboard(dashboardName)` - Switch to the Dashboard named `dashboardName`. - -`cy.addDashboard(dashboardName, columnsCount, switchInterval, expectFailure)` - Add Dashboard with a name `dashboardName`, number of columns equal to `columnsCount`, switching interval set to `switchInterval` and `expectFailure` flag which will check if creation dialog has been closed when set to `false` or if creation dialog is still visible when set to `true`. - -`cy.removeDashboard(dashboardNname)` - Remove the Dashboard named `dashboardName`. - -`cy.renewDashboards(username, password)` - API call which generates dashboard specified in `../fixtures/reorderingConfig.json` - -### Widget - -`createWidget(name)` - use any name from `functional/cypress-tests/cypress/fixtures/Widgets.js` for example: `Widgets.whiteSpace.name` - this will add configured widget to current board. Widget type will be deducted from name prop. - -`cy.clickAddWidgetButton()` - Click the Add Widget button visible after login. - -`cy.fillNewWidgetGeneral(widgetType, title, newLine, disabled, columnsCount, rowsCount)` - Fill out the General Tab of the Widget Creation dialog. `widgetType` specifies Widget to be added, `title` is it's name, `newLine` will set the widget to be added on a next row, following the last existing widget on a Dashboard when set to `true`, `disabled` will make the widget disabled when set to `true`. `columnsCount` and `rowsCount` will determine number of, respectively, columns and rows. - -`cy.fillSchedulePeriod(value)` - Fill out the Schedule Period field. - -`cy.confirmAddWidget()` - Confirm creation of a widget. - -`cy.removeWidget(name)` - Remove a widget on the page, specified by its name (title). - -### Widget - -`setTestCredentials(testCredentials, authToken)` - API call which adds new testCredentials - -`setTestEndpoints(testEndpoints, authToken)` - API call which adds new testEndpoints +# Cypress Automated Functional Tests + +## :construction: Work in progress :construction: + +Tests in this part of the repository are testing UI of the CogBoard. Get to know helper functions available right now (and update them if needed). + +## Running tests + +### CLI + +Quick use (i.e. before push): + +`./gradlew functionalTests` - will launch all specs, with local env config file. + +To override env add `-DcypressEnv=envName` system property. Which will run cypress config named `envName.json` under `functional/cypress-tests/cypress/config` directory. You must make sure that you created such a config in a first place. + +To customize config and specs to be launched, you have to run: + +`npx cypress run [--config-file path/to/config.json]`1 `[--spec path/to/spec.js]`2 from the `functional/cypress-tests` directory + +1 - provide custom config file path, by default it will use `functional/cypress-tests/cypress/config/local.json`. + +2 - provide spec file(s) to be launched. By default all specs are run. + +### GUI + +1. Go to `functional/cypress-tests` directory +1. Install Cypress `npm install` (run only once) +1. Open Cypress tools by executing `npx cypress open` + +## Contribution guide + +All cypress related commits should be done on branches starting with `automation/` + +### Tasks to do + +... + +### Coding conventions + +All tests should be written according to [Cypress.io Best Practices guide](https://docs.cypress.io/guides/references/best-practices.html 'Best Practices | Cypress Documentation'). Read it before starting to contribute. + +Additional project specific conventions: + +- For actions that have been already covered in other tests write `cy.request(...)` helper command to bypass UI and speed up the test execution time + - Good example would be the Widget tests. Dashboard creation and dashboard removal steps are performed in each of those tests and take around 2.5s. If we have used API calls instead we would be lookng at `2.5s * numOfWidgets` time reduction. +- Keep test data in separate files. i.e. `../fixtures/Widgets.js` +- No test case dependencies. Each test's expected initial state should be either prepared manually or by `cy.request(...)` call before test starts. +- Actions that impact on other specs (such as saving) should be kept in the same spec. +- Repetitive tests should be written in iterative manner: i.e. `../integration/widgets.js` or FE validation tests in `../integration/dashboards.js` +- ... + +## Available helpers + +Use helpers below to minimize specfiles size. Keep in mind that some of those helpers require certain state of app to work correctly, make sure you learn their code. + +### General + +`cy.saveState()` - Saves current state of the Dashboard + +### User + +`cy.login()` - Log in with credentials specified in configuration file. - _Currently `../cypress.json`_ + +`cy.logout()` - Log out of the application + +`getAuthenticationToken()` - Returns authentication token for username: admin + +`loginWithToken()` - Log in as admin + +### Dashboard + +`cy.openDrawer()` - Open the Dashboard Drawer + +`cy.closeDrawer()` - Close the Dashboard Drawer. + +`cy.chooseDashboard(dashboardName)` - Switch to the Dashboard named `dashboardName`. + +`cy.addDashboard(dashboardName, columnsCount, switchInterval, expectFailure)` - Add Dashboard with a name `dashboardName`, number of columns equal to `columnsCount`, switching interval set to `switchInterval` and `expectFailure` flag which will check if creation dialog has been closed when set to `false` or if creation dialog is still visible when set to `true`. + +`cy.removeDashboard(dashboardNname)` - Remove the Dashboard named `dashboardName`. + +`cy.renewDashboards(username, password)` - API call which generates dashboard specified in `../fixtures/reorderingConfig.json` + +### Widget + +`createWidget(name)` - use any name from `functional/cypress-tests/cypress/fixtures/Widgets.js` for example: `Widgets.whiteSpace.name` - this will add configured widget to current board. Widget type will be deducted from name prop. + +`cy.clickAddWidgetButton()` - Click the Add Widget button visible after login. + +`cy.fillNewWidgetGeneral(widgetType, title, newLine, disabled, columnsCount, rowsCount)` - Fill out the General Tab of the Widget Creation dialog. `widgetType` specifies Widget to be added, `title` is it's name, `newLine` will set the widget to be added on a next row, following the last existing widget on a Dashboard when set to `true`, `disabled` will make the widget disabled when set to `true`. `columnsCount` and `rowsCount` will determine number of, respectively, columns and rows. + +`cy.fillSchedulePeriod(value)` - Fill out the Schedule Period field. + +`cy.confirmAddWidget()` - Confirm creation of a widget. + +`cy.removeWidget(name)` - Remove a widget on the page, specified by its name (title). + +### Widget + +`setTestCredentials(testCredentials, authToken)` - API call which adds new testCredentials + +`setTestEndpoints(testEndpoints, authToken)` - API call which adds new testEndpoints diff --git a/functional/cypress-tests/cypress/fixtures/Widgets.js b/functional/cypress-tests/cypress/fixtures/Widgets.js index 40e8ea226..561b1d1a8 100644 --- a/functional/cypress-tests/cypress/fixtures/Widgets.js +++ b/functional/cypress-tests/cypress/fixtures/Widgets.js @@ -138,6 +138,13 @@ module.exports = { } ] }, + logsViewer: { + name: 'Log Viewer', + endpoint: 'endpoint6', + schedulePeriod: '60', + path: '/home/mock/example.txt', + parserType: 'default' + }, serviceCheck: { name: 'Service Check', schedulePeriod: '3', diff --git a/functional/cypress-tests/cypress/fixtures/credentialsEndpoints.js b/functional/cypress-tests/cypress/fixtures/credentialsEndpoints.js index 03e0430b5..39b2431bb 100644 --- a/functional/cypress-tests/cypress/fixtures/credentialsEndpoints.js +++ b/functional/cypress-tests/cypress/fixtures/credentialsEndpoints.js @@ -4,7 +4,8 @@ export const badCredentials = () => { password: 'xxxxxxxxxxx', passwordConf: 'zzz', user: 'xxxxxxxxxxxxxxxxxxxxxxxxxx', - label: ' ' + label: ' ', + sshKeyPassphrase: '' }; }; diff --git a/functional/cypress-tests/cypress/fixtures/logsViewer.js b/functional/cypress-tests/cypress/fixtures/logsViewer.js new file mode 100644 index 000000000..4f726080d --- /dev/null +++ b/functional/cypress-tests/cypress/fixtures/logsViewer.js @@ -0,0 +1,17 @@ +export const filters = { + startsWithA: { + label: 'starts with a', + regExp: '^a' + }, + amet: { + label: 'amet', + regExp: 'amet' + } +}; + +export const logLevels = [ + { level: 10, value: 'debug' }, + { level: 20, value: 'info' }, + { level: 30, value: 'warning' }, + { level: 40, value: 'error' } +]; diff --git a/functional/cypress-tests/cypress/integration/credentials.js b/functional/cypress-tests/cypress/integration/credentials.js index 572afbbf4..90adcc8ca 100644 --- a/functional/cypress-tests/cypress/integration/credentials.js +++ b/functional/cypress-tests/cypress/integration/credentials.js @@ -32,7 +32,7 @@ describe('Credentials', () => { .assertErrorMessageVisible( 'Label length must be less or equal to 25.', 'credential-form-auth-user-input-error' - ); + ) }); it('User can add new credentials without username, password and token.', () => { diff --git a/functional/cypress-tests/cypress/integration/logsViewer.js b/functional/cypress-tests/cypress/integration/logsViewer.js new file mode 100644 index 000000000..9d62c158e --- /dev/null +++ b/functional/cypress-tests/cypress/integration/logsViewer.js @@ -0,0 +1,201 @@ +import { logsViewer } from '../fixtures/Widgets'; +import { createWidget } from '../support/widget'; +import { + openAdvancedMenu, + closeAdvancedMenu, + addFilter, + isFilterVisibleInAdvancedMenu, + fillFormField, + logsMatchFilter, + submitForm, + assertChip, + logsMatchLogLevel, + selectLogLevel, +} from '../support/logsViewer/filters'; +import { filters, logLevels } from '../fixtures/logsViewer'; + +const dashboardName = 'Welcome to Cogboard'; +const ametFilter = filters.amet; +const startsWithAFilter = filters.startsWithA; + +describe('Logs Viewer', () => { + let widget; + + before(() => { + cy.visit('/'); + cy.login(); + cy.openDrawer(); + cy.chooseDashboard(dashboardName); + cy.clickAddWidgetButton(); + widget = createWidget(logsViewer.name).configure(false, { + cols: 8, + rows: 2, + }); + }); + + beforeEach(() => { + cy.viewport(1920, 1080); + }); + + describe('Filters', () => { + it('opens advanced filters modal', () => { + openAdvancedMenu(); + widget.assertText('h2', 'Advanced filters'); + closeAdvancedMenu(); + }); + + it('should add filter', () => { + openAdvancedMenu(); + addFilter(startsWithAFilter); + + isFilterVisibleInAdvancedMenu(widget, startsWithAFilter.label); + closeAdvancedMenu(); + }); + + it('filters correctly with 1 rule', () => { + logsMatchFilter(startsWithAFilter.regExp); + }); + + it('should select filters via multiselect dialog', () => { + assertChip(widget, startsWithAFilter.label); + widget.click('[data-cy="filters-chip"] .MuiChip-deleteIcon'); + assertChip(widget, startsWithAFilter.label, 'not.exist'); + + widget.click('[data-cy="filters-menu"]'); + widget.click('[data-cy="filters-menu-option"]'); + cy.get('[data-cy="filters-menu-option"]').type('{esc}'); + assertChip(widget, startsWithAFilter.label); + }); + + it('should edit filter', () => { + openAdvancedMenu(); + widget.click('[data-cy="edit-filter-edit-button"]'); + widget.assertText('h2', 'Edit filter'); + // check if form displays data to edit + cy.get('[data-cy="filter-form-label-input"]').should( + 'have.value', + startsWithAFilter.label + ); + widget.assertText( + '[data-cy="filter-form-reg-exp-input"]', + startsWithAFilter.regExp + ); + fillFormField('label', ametFilter.label); + fillFormField('reg-exp', ametFilter.regExp); + submitForm(); + + isFilterVisibleInAdvancedMenu(widget, ametFilter.label); + closeAdvancedMenu(); + + logsMatchFilter(ametFilter.regExp); + }); + + it('filters correctly with 2 rules', () => { + openAdvancedMenu(); + addFilter(startsWithAFilter); + closeAdvancedMenu(); + + logsMatchFilter(startsWithAFilter.regExp); + logsMatchFilter(ametFilter.regExp); + }); + + it('should delete filters', () => { + openAdvancedMenu(); + + cy.get('[data-cy="delete-filter-delete-button"]').each((filter) => { + cy.wrap(filter).click(); + cy.get('[data-cy="confirmation-dialog-ok"]').click(); + }); + + for (const filter in filters) { + widget.assertText( + 'span.MuiListItemText-primary', + filters[filter].label, + 'not.exist' + ); + } + closeAdvancedMenu(); + }); + }); + + describe('Log level', () => { + logLevels.forEach((selectedLevel) => { + it(`show logs with greater or equal level to ${selectedLevel.value}`, () => { + selectLogLevel(selectedLevel.value); + logsMatchLogLevel(selectedLevel, logLevels); + selectLogLevel('info'); // default + }); + }); + }); + + describe('Date span', () => { + it('sets begin date on CLEAR LOGS button click', () => { + widget.click('[data-cy="show-logs-from-now-button"'); + + // begin date span picker should not be empty + cy.get('[data-cy="date-time-picker-begin"] .MuiInput-root input').should( + 'not.have.value', + '' + ); + }); + + it('filters logs by begin date span', () => + cy.get('[data-cy="log-entry"]').should('not.exist')); + + it('removes date when X icon is clicked', () => { + widget.click('[data-cy="date-time-picker-begin-clear"]'); + // should be empty + cy.get('[data-cy="date-time-picker-begin"] .MuiInput-root input').should( + 'have.value', + '' + ); + cy.get('[data-cy="log-entry"]').should('exist'); + }); + }); + + describe('Quarantine', () => { + it('should allow logged in users to click quarantine button', () => { + cy.get('[data-cy="advanced-filters-button"]').click(); + cy.get('[data-cy="quarantine-show-dialog-button"]').should('exist'); + cy.get('[data-cy="advanced-filters-menu-exit-button"]').click(); + }); + + it('should not allow logged out users to click quarantine button', () => { + cy.get('[data-cy="user-login-logout-icon"]').click(); + cy.get('[data-cy="advanced-filters-button"]').click(); + cy.get('[data-cy="quarantine-show-dialog-button"]').should('not.exist'); + cy.get('[data-cy="advanced-filters-menu-exit-button"]').click(); + cy.login(); + }); + }); + + describe('Searchbar', () => { + it('shows search icon while there are less than 3 letters in input', () => { + cy.get('[data-cy="search-icon"]').should('exist'); + cy.get('[data-cy="search-input-field"]').type('m'); + cy.get('[data-cy="search-icon"]').should('exist'); + cy.get('[data-cy="search-input-field"]').type('e'); + cy.get('[data-cy="search-icon"]').should('exist'); + cy.get('[data-cy="search-input-field"]').type('s'); + cy.get('[data-cy="search-icon"]').should('not.exist'); + }); + + it('it shows close icon when at least 3 letters are in input', () => { + cy.get('[data-cy="close-icon"]').should('exist'); + }); + + it('shows highlight mark on logs fitting expression', () => { + cy.get('[data-cy="highlight-mark"]').each((mark) => { + cy.wrap(mark) + .closest('[data-cy="log-entry"]') + .contains('[data-cy="log-variable-data"] p', new RegExp('mes', 'gi')) + .should('exist'); + }); + }); + + it('clears input after clicking close icon', () => { + cy.get('[data-cy="close-icon"]').click(); + cy.get('[data-cy="search-input-field"]').invoke('val').should('be.empty'); + }); + }); +}); diff --git a/functional/cypress-tests/cypress/integration/refresh_widgets.js b/functional/cypress-tests/cypress/integration/refresh_widgets.js index c5a9218d7..1805c6c50 100644 --- a/functional/cypress-tests/cypress/integration/refresh_widgets.js +++ b/functional/cypress-tests/cypress/integration/refresh_widgets.js @@ -1,57 +1,57 @@ -import { createWidget } from '../support/widget'; -import Widgets from '../fixtures/Widgets'; - -describe('Refresh widgets', () => { - const jenkinsJobTitleFirst = 'Jenkins Job Test'; - const blueColor = 'rgb(25, 140, 189)'; - const redColor = 'rgb(225, 49, 47)'; - const fakeMocksUrl = 'http://fake-mocks:1234'; - const apiMocksUrl = 'http://api-mocks:8080'; - let jenkinsJobWidget1; - - beforeEach(() => { - cy.visit('/'); - cy.login(); - - jenkinsJobWidget1 = createJenkinsJobWidget(jenkinsJobTitleFirst); - cy.saveState(); - }); - - it('Widget will be updated after edit endpoints', () => { - jenkinsJobWidget1.assertBackground(blueColor); - - changeUrls(fakeMocksUrl); - jenkinsJobWidget1.assertBackground(redColor); - - changeUrls(apiMocksUrl); - jenkinsJobWidget1.assertBackground(blueColor); - - jenkinsJobWidget1.remove(); - cy.saveState(); - }); - - function changeUrls(url) { - cy.openSettings(); - cy.contains('li.MuiListItem-container', 'API Mocks') - .find('[data-cy="edit-endpoint-edit-button"]') - .click(); - cy.get('[data-cy="endpoint-form-url-input"]') - .clear() - .type(url) - .blur(); - cy.get('[data-cy="endpoint-form-public-url-input"]') - .clear() - .type(url) - .blur(); - cy.get('[data-cy="endpoint-form-submit-button"]').click(); - cy.get('[data-cy="settings-menu-exit-button"]').click(); - } - - function createJenkinsJobWidget(title) { - cy.clickAddWidgetButton(); - const widget = createWidget(Widgets.jenkinsJob.name); - widget.title = title; - widget.configure(false); - return widget; - } -}); +import { createWidget } from '../support/widget'; +import Widgets from '../fixtures/Widgets'; + +describe('Refresh widgets', () => { + const jenkinsJobTitleFirst = 'Jenkins Job Test'; + const blueColor = 'rgb(25, 140, 189)'; + const redColor = 'rgb(225, 49, 47)'; + const fakeMocksUrl = 'http://fake-mocks:1234'; + const apiMocksUrl = 'http://api-mocks:8080'; + let jenkinsJobWidget1; + + beforeEach(() => { + cy.visit('/'); + cy.login(); + + jenkinsJobWidget1 = createJenkinsJobWidget(jenkinsJobTitleFirst); + cy.saveState(); + }); + + it('Widget will be updated after edit endpoints', () => { + jenkinsJobWidget1.assertBackground(blueColor); + + changeUrls(fakeMocksUrl); + jenkinsJobWidget1.assertBackground(redColor); + + changeUrls(apiMocksUrl); + jenkinsJobWidget1.assertBackground(blueColor); + + jenkinsJobWidget1.remove(); + cy.saveState(); + }); + + function changeUrls(url) { + cy.openSettings(); + cy.contains('li.MuiListItem-container', 'API Mocks') + .find('[data-cy="edit-endpoint-edit-button"]') + .click(); + cy.get('[data-cy="endpoint-form-url-input"]') + .clear() + .type(url) + .blur(); + cy.get('[data-cy="endpoint-form-public-url-input"]') + .clear() + .type(url) + .blur(); + cy.get('[data-cy="endpoint-form-submit-button"]').click(); + cy.get('[data-cy="settings-menu-exit-button"]').click(); + } + + function createJenkinsJobWidget(title) { + cy.clickAddWidgetButton(); + const widget = createWidget(Widgets.jenkinsJob.name); + widget.title = title; + widget.configure(false); + return widget; + } +}); diff --git a/functional/cypress-tests/cypress/support/credential.js b/functional/cypress-tests/cypress/support/credential.js index 9ee77c41f..7028edfd7 100644 --- a/functional/cypress-tests/cypress/support/credential.js +++ b/functional/cypress-tests/cypress/support/credential.js @@ -58,6 +58,17 @@ class Credentials { return this; } + applySSHKeyPassphrase(config) { + if (config !== undefined) { + this.config = config; + } + cy.get('[data-cy="credential-form-auth-ssh-key-passphrase-input"]') + .clear() + .type(this.config.sshKeyPassphrase) + .blur(); + return this; + } + save() { cy.get('[data-cy="credential-form-submit-button"]').click(); return this; @@ -77,7 +88,9 @@ class Credentials { } assertErrorMessageVisible(message, dataCYName) { - cy.contains(`[data-cy^="${dataCYName}"]`, message).should('is.visible'); + cy.contains(`[data-cy^="${dataCYName}"]`, message) + .scrollIntoView() + .should('is.visible'); return this; } } diff --git a/functional/cypress-tests/cypress/support/logsViewer/filters.js b/functional/cypress-tests/cypress/support/logsViewer/filters.js new file mode 100644 index 000000000..573a9a4b8 --- /dev/null +++ b/functional/cypress-tests/cypress/support/logsViewer/filters.js @@ -0,0 +1,60 @@ +const logsContains = (logPartSelector, regExp) => + cy.get('[data-cy="log-entry"] ').each(log => + cy + .wrap(log) + .contains(logPartSelector, regExp) + .should('exist') + ); + +export const logsMatchFilter = regExp => + logsContains('[data-cy="log-variable-data"] p', new RegExp(regExp)); + +export const logsMatchLogLevel = (selectedLevel, levels) => { + const greaterLogLevels = levels.filter( + level => level.level >= selectedLevel.level + ); + const regExp = new RegExp( + greaterLogLevels.map(lvl => lvl.value).join('|'), + 'i' + ); + logsContains('[data-cy="log-entry-level"]', regExp); +}; + +export const selectLogLevel = levelSlug => { + cy.get('[data-cy="log-level-menu"]').click(); + cy.get(`[data-cy="log-level-menu-option-${levelSlug}"]`).click(); +}; + +export const openAdvancedMenu = () => + cy.get('[data-cy="advanced-filters-button"]').click(); + +export const closeAdvancedMenu = () => + cy.get('[data-cy="advanced-filters-menu-exit-button"]').click(); + +export const isFilterVisibleInAdvancedMenu = (widget, label) => { + widget.assertText('span.MuiListItemText-primary', label); + widget.isChecked( + '.MuiListItemSecondaryAction-root input[type="checkbox"]', + true + ); +}; + +export const assertChip = (widget, label, chainer = 'exist') => { + widget.assertText('[data-cy="filters-chip"] .MuiChip-label', label, chainer); +}; + +export const fillFormField = (field, value) => { + cy.get(`[data-cy="filter-form-${field}-input"]`) + .clear() + .type(value); +}; + +export const submitForm = () => + cy.get('[data-cy="filter-form-submit-button"]').click(); + +export const addFilter = filter => { + cy.get('[data-cy="add-filter-add-button"]').click(); + fillFormField('label', filter.label); + fillFormField('reg-exp', filter.regExp); + submitForm(); +}; diff --git a/functional/cypress-tests/cypress/support/widget.js b/functional/cypress-tests/cypress/support/widget.js index d04b68886..78b06f3e1 100644 --- a/functional/cypress-tests/cypress/support/widget.js +++ b/functional/cypress-tests/cypress/support/widget.js @@ -7,8 +7,15 @@ class Widget { this.title = `Test-${name}`; } - configure(disabled) { - cy.fillNewWidgetGeneral(this.name, this.title, false, disabled, 2, 1); + configure(disabled, size = { cols: 2, rows: 1 }) { + cy.fillNewWidgetGeneral( + this.name, + this.title, + false, + disabled, + size.cols, + size.rows + ); fillDynamicTab(this); cy.confirmAddWidget(); return this; diff --git a/functional/cypress-tests/cypress/support/widgetAssertions.js b/functional/cypress-tests/cypress/support/widgetAssertions.js index 2e826b6b6..f3527c048 100644 --- a/functional/cypress-tests/cypress/support/widgetAssertions.js +++ b/functional/cypress-tests/cypress/support/widgetAssertions.js @@ -82,6 +82,11 @@ export function validateLinkList(widget) { } } +export function validateLogsViewer(widget) { + widget.assertText('label', 'Log level'); + widget.assertText('p', 'Level'); +} + export function validateServiceCheck(widget) { widget .assertBackground('rgb(1, 148, 48)') @@ -182,6 +187,9 @@ export function validateWidgetConfig(widget) { case 'Link List': validateLinkList(widget); break; + case 'Log Viewer': + validateLogsViewer(widget); + break; case 'Service Check': validateServiceCheck(widget); break; diff --git a/functional/cypress-tests/cypress/support/widgetDynamicTab.js b/functional/cypress-tests/cypress/support/widgetDynamicTab.js index 51bca573e..a45bc0291 100644 --- a/functional/cypress-tests/cypress/support/widgetDynamicTab.js +++ b/functional/cypress-tests/cypress/support/widgetDynamicTab.js @@ -86,6 +86,15 @@ export function fillLinksList() { } } +export function fillLogsViewer() { + cy.get('[data-cy="widget-form-endpoint-input"]').click(); + cy.get(`[data-value="${Widgets.logsViewer.endpoint}"]`).click(); + cy.fillSchedulePeriod(Widgets.logsViewer.schedulePeriod); + cy.get('[data-cy="widget-form-path-input"]').type(Widgets.logsViewer.path); + cy.get('[data-cy="widget-form-log-parser-field-input"]').click(); + cy.get(`[data-value="${Widgets.logsViewer.parserType}"]`).click(); +} + export function fillServiceCheck() { cy.fillSchedulePeriod(Widgets.serviceCheck.schedulePeriod); cy.get('[data-cy="widget-form-request-method-input"]').click(); @@ -218,6 +227,9 @@ export function fillDynamicTab(widget) { case 'Link List': selectTabAndFillData(fillLinksList); break; + case 'Log Viewer': + selectTabAndFillData(fillLogsViewer); + break; case 'Service Check': selectTabAndFillData(fillServiceCheck); break; diff --git a/functional/cypress-tests/cypress/support/widgets.js b/functional/cypress-tests/cypress/support/widgets.js index 841b5f6bc..96dd95809 100644 --- a/functional/cypress-tests/cypress/support/widgets.js +++ b/functional/cypress-tests/cypress/support/widgets.js @@ -1,59 +1,59 @@ -Cypress.Commands.add('clickAddWidgetButton', () => { - cy.get('[data-cy="main-template-add-widget-button"]').click(); -}); - -Cypress.Commands.add( - 'fillNewWidgetGeneral', - ( - type = 'Text', - title = 'Text Title', - newLine = false, - disabled = false, - columns = 2, - rows = 4 - ) => { - cy.get('[data-cy="widget-form-type-input"]').click(); - cy.contains('li', type).click(); - if (type !== 'Checkbox' && type !== 'White Space') { - cy.contains('span', type).should('is.visible'); - } - cy.get('[data-cy="widget-form-title-input"]') - .clear() - .type(title); - cy.get('[data-cy="widget-form-columns-input"]').type( - '{selectall}' + columns.toString() - ); - cy.get('[data-cy="widget-form-rows-input"]').type( - '{selectall}' + rows.toString() - ); - if (newLine == true) { - cy.get('[data-cy="widget-form-go-new-line-checkbox"]').click(); - } - if (disabled == true) { - cy.get('[data-cy="widget-form-disabled-input"]').click(); - } - } -); - -Cypress.Commands.add('fillSchedulePeriod', value => { - cy.get('[data-cy="widget-form-schedule-period-input"]').type( - '{selectall}' + value - ); -}); - -Cypress.Commands.add('confirmAddWidget', () => { - cy.get('[data-cy="widget-form-submit-button"]').click(); -}); - -Cypress.Commands.add('removeWidget', name => { - cy.contains(name) - .parents('.MuiCardHeader-root') - .trigger('mouseover') - .find('[data-cy="more-menu-button"]') - .click(); - cy.get('div[id="more-menu"]') - .not('[aria-hidden="true"]') - .find('[data-cy="widget-delete"]') - .click(); - cy.get('[data-cy="confirmation-dialog-ok"]').click(); -}); +Cypress.Commands.add('clickAddWidgetButton', () => { + cy.get('[data-cy="main-template-add-widget-button"]').click(); +}); + +Cypress.Commands.add( + 'fillNewWidgetGeneral', + ( + type = 'Text', + title = 'Text Title', + newLine = false, + disabled = false, + columns = 2, + rows = 4 + ) => { + cy.get('[data-cy="widget-form-type-input"]').click(); + cy.contains('li', type).click(); + if (type !== 'Checkbox' && type !== 'White Space') { + cy.contains('span', type).should('is.visible'); + } + cy.get('[data-cy="widget-form-title-input"]') + .clear() + .type(title); + cy.get('[data-cy="widget-form-columns-input"]').type( + '{selectall}' + columns.toString() + ); + cy.get('[data-cy="widget-form-rows-input"]').type( + '{selectall}' + rows.toString() + ); + if (newLine == true) { + cy.get('[data-cy="widget-form-go-new-line-checkbox"]').click(); + } + if (disabled == true) { + cy.get('[data-cy="widget-form-disabled-input"]').click(); + } + } +); + +Cypress.Commands.add('fillSchedulePeriod', value => { + cy.get('[data-cy="widget-form-schedule-period-input"]').type( + '{selectall}' + value + ); +}); + +Cypress.Commands.add('confirmAddWidget', () => { + cy.get('[data-cy="widget-form-submit-button"]').click(); +}); + +Cypress.Commands.add('removeWidget', name => { + cy.contains(name) + .parents('.MuiCardHeader-root') + .trigger('mouseover') + .find('[data-cy="more-menu-button"]') + .click(); + cy.get('div[id="more-menu"]') + .not('[aria-hidden="true"]') + .find('[data-cy="widget-delete"]') + .click(); + cy.get('[data-cy="confirmation-dialog-ok"]').click(); +}); diff --git a/gradle.properties b/gradle.properties index 5839e4642..6ca09415c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,8 @@ docker.app.container.name=cogboard ws.port=9001 #App -app.port=8092 \ No newline at end of file +app.port=8092 + +# Mongo +mongo.user=s_root +mongo.password=root \ No newline at end of file diff --git a/gradle/docker.gradle.kts b/gradle/docker.gradle.kts index 33f38cb1a..889b314a5 100644 --- a/gradle/docker.gradle.kts +++ b/gradle/docker.gradle.kts @@ -29,6 +29,8 @@ val cypressConfigPath = "cypress/config/$cypressEnvCode.json" val network = "${project.name}-local_cognet" val wsPort = project.property("ws.port") val appPort = project.property("app.port") +val mongoUsername = project.property("mongo.user") +val mongoPassword = project.property("mongo.password") logger.lifecycle(">> dockerContainerName: $dockerContainerName") logger.lifecycle(">> dockerImageName: $dockerImageName") @@ -90,6 +92,8 @@ tasks { register("deployLocal") { environment.put("COGBOARD_VERSION", version) + environment.put("MONGO_USER", mongoUsername) + environment.put("MONGO_PASSWORD", mongoPassword) group = "swarm" commandLine = listOf("docker", "stack", "deploy", "-c", "${project.name}-local-compose.yml", "${project.name}-local") dependsOn("initSwarm", "buildImage", "awaitLocalStackUndeployed") diff --git a/gradle/prepareCogboardCompose.gradle.kts b/gradle/prepareCogboardCompose.gradle.kts index 3e75cc31b..236b9baad 100644 --- a/gradle/prepareCogboardCompose.gradle.kts +++ b/gradle/prepareCogboardCompose.gradle.kts @@ -9,6 +9,8 @@ fun createComposeFile() { val composeFilePath = "$rootDir/cogboard-compose.yml" logger.lifecycle(">> createZip >> Creating $composeFilePath") + val user = project.property("mongo.user") ?: "root" + val password = project.property("mongo.password") ?: "root" File(composeFilePath).writeText("""version: "3.7" services: @@ -16,8 +18,20 @@ services: image: "cogboard/cogboard-app:$currentVersion" environment: - COGBOARD_VERSION=$currentVersion + - MONGO_USERNAME=$user + - MONGO_PASSWORD=$password volumes: - "./mnt:/data" + + mongo-logs-storage: + image: mongo:4 + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: "$user" + MONGO_INITDB_ROOT_PASSWORD: "$password" + MONGO_INITDB_DATABASE: "logs" + volumes: + - "./mnt/mongo:/data/db" frontend: image: "cogboard/cogboard-web:$currentVersion" diff --git a/knotx/conf/openapi.yaml b/knotx/conf/openapi.yaml index f04d96eb8..a6b721f76 100644 --- a/knotx/conf/openapi.yaml +++ b/knotx/conf/openapi.yaml @@ -185,6 +185,19 @@ paths: responses: default: description: Widget Content Refresh Handler + /api/logs/{id}: + parameters: + - name: id + in: path + description: id of the widget for which to get logs + required: true + schema: + type: string + get: + operationId: logs-get-handler + responses: + default: + description: Initial Logs Handler components: securitySchemes: cogboardAuth: diff --git a/knotx/conf/routes/operations.conf b/knotx/conf/routes/operations.conf index aff63a164..d1d715187 100644 --- a/knotx/conf/routes/operations.conf +++ b/knotx/conf/routes/operations.conf @@ -249,6 +249,20 @@ routingOperations = ${routingOperations} [ } ] } + { + operationId = logs-get-handler + handlers = [ + { + name = logs-handler + config { + address = cogboard.logs.get + method = get + payload = params + refresh = false + } + } + ] + } { operationId = version-handler handlers = [{ name = version-handler }] diff --git a/mnt/mongo/.gitkeep b/mnt/mongo/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/settings.gradle.kts b/settings.gradle.kts index 0700e243a..dd77ec502 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ rootProject.name = "cogboard" include("cogboard-app") include("cogboard-webapp") +include("ssh") \ No newline at end of file diff --git a/ssh/Dockerfile b/ssh/Dockerfile new file mode 100644 index 000000000..515fa1c9b --- /dev/null +++ b/ssh/Dockerfile @@ -0,0 +1,32 @@ +FROM lscr.io/linuxserver/openssh-server + +ENV PUBLIC_KEY="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCuNrl6m54JiRbCtqQEQlzB3Lzaj4wzNirMvxJy9lRBePNd1Asqob6kTY2DwKV2n79f4A8vmpz+Q/MMkljFSEARX/H9qP/KfZx1OPNFzxx69LVQJZQCsLwabVHrKqGFu+wfzWWA1pBgTo7EiIsTkMP/RSqZpt4fO8F06viUMguTeSM40JaG8oC2jnOMSL0ZHB8Ng/YirGu4L04JjgjW0k23SAO8v4IJPOku5l4lXLqJYkAVkZwgrD9mzy1MbtuOiNZcMkLW9PPY17MYRrLZyu1rQb9hnZqT2Q4Y0dxYG5PDbfHhipeNr6gDRalfzztw3KBMVNflD8rNcGN3ibw2u4XSmF3yIpLcvo195mYeg6nVmj6OTgdGuy5Jkafe5EzrEhFMm2Fgj7feKKztzhpDK2r+HStSN3BRISmknF/QWMmWRXiIIrWxeVPqUoJsSLPhQQq0++e8IZ9P9Q9g2etsu2nL3jHIB9JbM+WbkapvTs2Z7k7h3m/Tl/dENcKlrkIlgHs=" +ENV PASSWORD_ACCESS=true +ENV USER_NAME=mock +ENV USER_PASSWORD=TLQuoLMn*T89&Y*r*YqHviSFH6MkR!4E + +RUN mkdir /home/mock +RUN echo $'#!/bin/bash\n\ +labels=(DEBUG INFO WARNING ERROR)\n\ +words=(Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sit amet massa sit amet mi feugiat lobortis. Morbi ultrices hendrerit luctus. Donec fermentum viverra viverra. Integer convallis sapien sit amet facilisis pretium. Ut quis commodo odio, ac bibendum urna. Ut imperdiet ante sed ex sollicitudin auctor. Nulla vel eros sit amet velit vulputate suscipit a malesuada felis. Curabitur commodo, erat eget condimentum tristique, ante diam porttitor nulla, condimentum vulputate nibh ex sit amet velit. Vestibulum vel lectus bibendum, pellentesque nisl id, vehicula tellus. Suspendisse sem turpis, dignissim quis aliquet nec, laoreet sit amet urna. Nulla non euismod tellus, id varius)\n\ +\n\ +COUNT=5\n\ +if ! [ -z "$1" ]; then\n\ + COUNT=$1\n\ +fi\n\ +\n\ +for run in $( seq 1 $COUNT ); do\n\ + d=$(date +%Y-%m-%d:%H:%M:%S)\n\ + l=${labels[$(($RANDOM%4))]}\n\ + w=()\n\ + wCount=$(($RANDOM%5+5))\n\ + for (( i=0; i<$wCount; i++ ))\n\ + do\n\ + w[i]=${words[$(($RANDOM%${#words[@]}))]}\n\ + done\n\ + echo $d "*"${l}"* [FelixStartLevel] " ${w[*]}\n\ +done' > /home/mock/gen.sh +RUN chmod +x /home/mock/gen.sh +RUN /home/mock/gen.sh 50 > /home/mock/example.txt +RUN echo '* * * * * /home/mock/gen.sh 10 >> /home/mock/example.txt' > /etc/crontabs/root +CMD /usr/sbin/crond -l 2 -f \ No newline at end of file diff --git a/ssh/build.gradle.kts b/ssh/build.gradle.kts new file mode 100644 index 000000000..13232d3c4 --- /dev/null +++ b/ssh/build.gradle.kts @@ -0,0 +1,14 @@ +import com.bmuschko.gradle.docker.tasks.image.DockerBuildImage + +plugins { + id("base") + id("com.bmuschko.docker-remote-api") +} + +tasks { + register("buildImage") { + group = "docker" + inputDir.set(file(projectDir)) + images.add("ssh-server") + } +} \ No newline at end of file