From 6b4b2922462c5e409c0d66db0765fad4d61052e7 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Sun, 29 Mar 2026 00:20:52 -0400 Subject: [PATCH 01/11] lint: enable detekt rule: NestedScopeFunctions --- backend/detekt.yml | 2 +- .../translation/GrafanaTranslator.kt | 22 +++--- .../notifications/services/EmailService.kt | 22 +++--- .../synthetics/routes/SyntheticsService.kt | 17 ++--- .../com/moneat/services/EventServiceTest.kt | 70 +++++++++---------- .../moneat/testsupport/RouteTestSupport.kt | 2 +- 6 files changed, 61 insertions(+), 74 deletions(-) diff --git a/backend/detekt.yml b/backend/detekt.yml index cd496cad..b674a7bc 100644 --- a/backend/detekt.yml +++ b/backend/detekt.yml @@ -52,7 +52,7 @@ complexity: NestedBlockDepth: active: false NestedScopeFunctions: - active: false + active: true exceptions: TooGenericExceptionCaught: diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 622c902e..889e8654 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -261,15 +261,16 @@ class GrafanaTranslator : DashboardTranslator { } "value" -> { val opts = mapObj["options"]?.jsonObject ?: return@mapNotNull null - opts.entries.firstOrNull()?.let { (key, entry) -> - val result = entry.jsonObject["result"]?.jsonObject ?: return@let null - val text = result["text"]?.jsonPrimitive?.contentOrNull ?: return@let null - val color = result["color"]?.jsonPrimitive?.contentOrNull - buildJsonObject { - put("value", key) - put("text", text) - color?.let { put("color", it) } - } + val firstEntry = opts.entries.firstOrNull() ?: return@mapNotNull null + val key = firstEntry.key + val entry = firstEntry.value + val result = entry.jsonObject["result"]?.jsonObject ?: return@mapNotNull null + val text = result["text"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val color = result["color"]?.jsonPrimitive?.contentOrNull + buildJsonObject { + put("value", key) + put("text", text) + color?.let { put("color", it) } } } else -> null @@ -299,7 +300,8 @@ class GrafanaTranslator : DashboardTranslator { )?.jsonObject?.get("type")?.jsonPrimitive?.contentOrNull?.let { config["scaleType"] = it } val options = panelJson["options"]?.jsonObject - options?.get("legend")?.jsonObject?.let { legend -> + val legend = options?.get("legend")?.jsonObject + if (legend != null) { legend["placement"]?.jsonPrimitive?.contentOrNull?.let { config["legendPlacement"] = it } legend["displayMode"]?.jsonPrimitive?.contentOrNull?.let { mode -> config["legendMode"] = when (mode) { diff --git a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt index 0c0e955d..a40b71b0 100644 --- a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt +++ b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt @@ -214,29 +214,23 @@ class EmailService { var success = false try { + val textPart = + MimeBodyPart().apply { + setText(textBody, "UTF-8") + } + val htmlPart = + MimeBodyPart().apply { + setContent(htmlBody, "text/html; charset=UTF-8") + } val message = MimeMessage(mailSession).apply { setFrom(InternetAddress(fromEmail, "Moneat")) setRecipients(Message.RecipientType.TO, InternetAddress.parse(to)) setSubject(subject) - // Create multipart message with both HTML and text val multipart = MimeMultipart("alternative") - - // Add text part - val textPart = - MimeBodyPart().apply { - setText(textBody, "UTF-8") - } multipart.addBodyPart(textPart) - - // Add HTML part - val htmlPart = - MimeBodyPart().apply { - setContent(htmlBody, "text/html; charset=UTF-8") - } multipart.addBodyPart(htmlPart) - setContent(multipart) } diff --git a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt index 834c7252..e7ecd197 100644 --- a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt +++ b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt @@ -139,16 +139,13 @@ class SyntheticsService( organizationId: Int, request: UpdateSyntheticTestRequest ): SyntheticTestResponse? { - request.retryCount?.let { rc -> - request.retryIntervalMs?.let { ri -> - validateRetryParams(rc, ri) - } ?: validateRetryParams(rc, RETRY_INTERVAL_MS_DEFAULT) - } - request.retryIntervalMs?.let { ri -> - validateRetryParams( - request.retryCount ?: RETRY_COUNT_DEFAULT, - ri - ) + val rc = request.retryCount + val ri = request.retryIntervalMs + if (rc != null) { + validateRetryParams(rc, ri ?: RETRY_INTERVAL_MS_DEFAULT) + } + if (ri != null) { + validateRetryParams(rc ?: RETRY_COUNT_DEFAULT, ri) } val updated = transaction { SyntheticTests diff --git a/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt b/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt index 86bd0525..f6e31ede 100644 --- a/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt +++ b/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt @@ -150,46 +150,40 @@ class EventServiceTest { ) val fingerprint1 = - event1.let { - it.exception?.let { exc -> - val firstException = exc.values.firstOrNull() - listOf( - firstException?.type, - firstException - ?.stacktrace - ?.frames - ?.lastOrNull() - ?.function, - firstException - ?.stacktrace - ?.frames - ?.lastOrNull() - ?.filename - ) - .filterNotNull() - } ?: emptyList() - } + event1.exception?.let { exc -> + val firstException = exc.values.firstOrNull() + listOf( + firstException?.type, + firstException + ?.stacktrace + ?.frames + ?.lastOrNull() + ?.function, + firstException + ?.stacktrace + ?.frames + ?.lastOrNull() + ?.filename + ).filterNotNull() + } ?: emptyList() val fingerprint2 = - event2.let { - it.exception?.let { exc -> - val firstException = exc.values.firstOrNull() - listOf( - firstException?.type, - firstException - ?.stacktrace - ?.frames - ?.lastOrNull() - ?.function, - firstException - ?.stacktrace - ?.frames - ?.lastOrNull() - ?.filename - ) - .filterNotNull() - } ?: emptyList() - } + event2.exception?.let { exc -> + val firstException = exc.values.firstOrNull() + listOf( + firstException?.type, + firstException + ?.stacktrace + ?.frames + ?.lastOrNull() + ?.function, + firstException + ?.stacktrace + ?.frames + ?.lastOrNull() + ?.filename + ).filterNotNull() + } ?: emptyList() assertEquals(fingerprint1, fingerprint2, "Same errors should generate identical fingerprints for deduplication") } diff --git a/backend/src/test/kotlin/com/moneat/testsupport/RouteTestSupport.kt b/backend/src/test/kotlin/com/moneat/testsupport/RouteTestSupport.kt index bac1ebea..95881a0c 100644 --- a/backend/src/test/kotlin/com/moneat/testsupport/RouteTestSupport.kt +++ b/backend/src/test/kotlin/com/moneat/testsupport/RouteTestSupport.kt @@ -87,7 +87,7 @@ object RouteTestSupport { .withIssuer(ISSUER) .withAudience(AUDIENCE) .withClaim("userId", userId) - .apply { orgId?.let { withClaim("orgId", it) } } + .apply { if (orgId != null) withClaim("orgId", orgId) } .withClaim("email", email) .sign(Algorithm.HMAC256(secret)) } From 7ff39eb10ba678c6a2f2722c21131ccab5b33310 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Tue, 31 Mar 2026 09:09:12 -0400 Subject: [PATCH 02/11] fix: address CodeRabbit feedback (round 1) for #298 --- .../com/moneat/dashboards/translation/GrafanaTranslator.kt | 1 + .../kotlin/com/moneat/notifications/services/EmailService.kt | 1 + .../kotlin/com/moneat/synthetics/routes/SyntheticsService.kt | 2 ++ backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt | 1 + 4 files changed, 5 insertions(+) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 889e8654..7c343895 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -51,6 +51,7 @@ private const val MONEAT_COLS = 12 private const val GRAFANA_ROW_PX = 30.0 private const val MONEAT_ROW_PX = 30.0 +/** Imports and exports dashboards between Grafana JSON and Moneat dashboard models. */ class GrafanaTranslator : DashboardTranslator { private val widgetTypeMap = mapOf( diff --git a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt index a40b71b0..944a0fca 100644 --- a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt +++ b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt @@ -60,6 +60,7 @@ private const val BADGE_NEGATIVE = private const val BADGE_NEUTRAL = "font-weight:500;background-color:#f5f5f5;" + "border:1px solid #e5e5e5;color:#737373;" +/** Sends transactional and notification email via Jakarta Mail and HTML templates. */ class EmailService { private val config = ApplicationConfig("application.conf") private val fromEmail = config.property("email.from").getString() diff --git a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt index e7ecd197..7014006b 100644 --- a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt +++ b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt @@ -47,6 +47,8 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import com.moneat.utils.suspendRunCatching + +/** Manages synthetic HTTP checks, variables, execution, and ClickHouse result storage. */ class SyntheticsService( private val emailService: EmailService = EmailService(), private val slackService: SlackService = SlackService(), diff --git a/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt b/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt index f6e31ede..ad9e4b94 100644 --- a/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt +++ b/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt @@ -123,6 +123,7 @@ class EventServiceTest { // ──── EVENT FINGERPRINTING TESTS (P0) ──── + /** Asserts identical primary exception metadata yields the same deduplication fingerprint. */ @Test fun `same error with identical exception generates same fingerprint for deduplication`() { val event1 = From 67973234e2d734e40d17561fa64c68c0b5394293 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Tue, 31 Mar 2026 09:16:47 -0400 Subject: [PATCH 03/11] docs: add KDoc for CodeRabbit doc coverage on #298 touchpoints Add concise KDoc on refactored helpers and related entry points (extractDisplayConfig, escapeHtml, sendEmail, updateTest, EventServiceTest.setup) to address docstring coverage feedback alongside existing class docs. Made-with: Cursor --- .../com/moneat/dashboards/translation/GrafanaTranslator.kt | 1 + .../kotlin/com/moneat/notifications/services/EmailService.kt | 5 +++++ .../kotlin/com/moneat/synthetics/routes/SyntheticsService.kt | 1 + .../src/test/kotlin/com/moneat/services/EventServiceTest.kt | 1 + 4 files changed, 8 insertions(+) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 7c343895..9d02d321 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -209,6 +209,7 @@ class GrafanaTranslator : DashboardTranslator { private fun scaleGridValue(grafanaUnits: Int): Int = (grafanaUnits * GRAFANA_ROW_PX / MONEAT_ROW_PX).roundToInt() + /** Builds Moneat display config key/value strings from Grafana panel fieldConfig, mappings, and options. */ private fun extractDisplayConfig(panelJson: JsonObject): Map { val config = mutableMapOf() diff --git a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt index 944a0fca..5cce1b0c 100644 --- a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt +++ b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt @@ -45,6 +45,7 @@ import com.moneat.utils.suspendRunCatching private val logger = KotlinLogging.logger {} +/** Escapes HTML special characters for safe inclusion in email templates. */ private fun String.escapeHtml(): String = replace("&", "&") .replace("<", "<") @@ -188,6 +189,10 @@ class EmailService { sendEmail(toEmail, subject, htmlBody, textBody, "org_invitation") } + /** + * Sends a multipart alternative (plain text + HTML) message when SMTP is configured; + * otherwise logs a preview and records a failed send for metrics. + */ fun sendEmail( to: String, subject: String, diff --git a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt index 7014006b..148ba110 100644 --- a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt +++ b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt @@ -136,6 +136,7 @@ class SyntheticsService( } } + /** Applies partial updates to a synthetic test; validates retry fields when either count or interval is present. */ fun updateTest( testId: UUID, organizationId: Int, diff --git a/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt b/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt index ad9e4b94..dbd1c729 100644 --- a/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt +++ b/backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt @@ -67,6 +67,7 @@ class EventServiceTest { private val validPublicKey = "test-public-key-valid" private val inactivePublicKey = "test-public-key-inactive" + /** Constructs [EventService] with default and scenario-specific [EventRepository] mocks. */ @BeforeTest fun setup() { eventService = EventService(eventRepository = eventRepository) From 3cce801895a2a1c4c48b3ed2b3d70446f51ca5a6 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Tue, 31 Mar 2026 12:04:32 -0400 Subject: [PATCH 04/11] fix: address SonarQube issues (round 1) for #298 --- ee/backend/detekt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/backend/detekt.yml b/ee/backend/detekt.yml index a273ceee..f53a6b03 100644 --- a/ee/backend/detekt.yml +++ b/ee/backend/detekt.yml @@ -52,7 +52,7 @@ complexity: NestedBlockDepth: active: false NestedScopeFunctions: - active: false + active: true exceptions: TooGenericExceptionCaught: From 3784f4ccef3fd72925161dd0b3c48b92c5bfa403 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Thu, 2 Apr 2026 09:37:22 -0400 Subject: [PATCH 05/11] fix: address CodeRabbit feedback (round 1) for #298 --- .../kotlin/com/moneat/notifications/services/EmailService.kt | 2 ++ .../kotlin/com/moneat/synthetics/routes/SyntheticsService.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt index 5cce1b0c..ac3c8482 100644 --- a/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt +++ b/backend/src/main/kotlin/com/moneat/notifications/services/EmailService.kt @@ -91,6 +91,7 @@ class EmailService { Session.getInstance( props, object : Authenticator() { + /** Credentials used for authenticated SMTP submission. */ override fun getPasswordAuthentication(): PasswordAuthentication { return PasswordAuthentication(smtpUsername, smtpPassword) } @@ -292,6 +293,7 @@ class EmailService { } } + /** Records send outcome in [EmailsSent] for the recipient's organization when resolvable. */ private fun trackEmailSent( recipient: String, emailType: String, diff --git a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt index 148ba110..efaba334 100644 --- a/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt +++ b/backend/src/main/kotlin/com/moneat/synthetics/routes/SyntheticsService.kt @@ -522,6 +522,7 @@ class SyntheticsService( } } + /** Ensures retry count and interval are non-negative before create/update. */ private fun validateRetryParams(retryCount: Int, retryIntervalMs: Int) { require(retryCount >= 0) { "retryCount must be non-negative, got $retryCount" From 41912e8aa5e8d32fc3dd6d670b4717e9ae2d33f1 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Thu, 2 Apr 2026 09:52:50 -0400 Subject: [PATCH 06/11] fix: address SonarQube issues (round 1) for #298 --- .../translation/GrafanaTranslator.kt | 226 ++++++++++-------- 1 file changed, 124 insertions(+), 102 deletions(-) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 9d02d321..8ae0a5b5 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -212,124 +212,146 @@ class GrafanaTranslator : DashboardTranslator { /** Builds Moneat display config key/value strings from Grafana panel fieldConfig, mappings, and options. */ private fun extractDisplayConfig(panelJson: JsonObject): Map { val config = mutableMapOf() - - val fieldConfig = panelJson["fieldConfig"]?.jsonObject - val fieldDefaults = fieldConfig?.get("defaults")?.jsonObject + val fieldDefaults = panelJson["fieldConfig"]?.jsonObject?.get("defaults")?.jsonObject val defaults = fieldDefaults?.get("custom")?.jsonObject + val options = panelJson["options"]?.jsonObject - // Unit and decimals from fieldConfig.defaults - fieldDefaults?.get("unit")?.jsonPrimitive?.contentOrNull?.let { config["unit"] = it } - fieldDefaults?.get("decimals")?.jsonPrimitive?.intOrNull?.let { config["decimals"] = it.toString() } - - // Thresholds from fieldConfig.defaults.thresholds - fieldDefaults?.get("thresholds")?.jsonObject?.let { thresholds -> - val steps = thresholds["steps"]?.jsonArray - if (steps != null && steps.size > 0) { - val moneatThresholds = steps.mapNotNull { step -> - val stepObj = step.jsonObject - val valuePrim = stepObj["value"]?.jsonPrimitive - val value = valuePrim?.intOrNull - ?: if (valuePrim?.contentOrNull == null) 0 else return@mapNotNull null - val color = stepObj["color"]?.jsonPrimitive?.contentOrNull - ?: return@mapNotNull null - buildJsonObject { - put("value", value) - put("color", color) - } - } - if (moneatThresholds.isNotEmpty()) { - config["thresholds"] = JsonArray(moneatThresholds).toString() - } + putFieldDefaultUnitAndDecimals(fieldDefaults, config) + putThresholdsFromFieldDefaults(fieldDefaults, config) + putValueMappingsFromFieldDefaults(fieldDefaults, config) + putCustomDisplayDefaults(defaults, config) + putLegendFromOptions(options, config) + putGaugeRangeAndBarGaugeOptions(fieldDefaults, options, config) + extractGrafanaTransformations(panelJson, config) + + return config + } + + private fun putFieldDefaultUnitAndDecimals( + fieldDefaults: JsonObject?, + config: MutableMap + ) { + val unit = fieldDefaults?.get("unit")?.jsonPrimitive?.contentOrNull + if (unit != null) config["unit"] = unit + val decimals = fieldDefaults?.get("decimals")?.jsonPrimitive?.intOrNull + if (decimals != null) config["decimals"] = decimals.toString() + } + + private fun putThresholdsFromFieldDefaults( + fieldDefaults: JsonObject?, + config: MutableMap + ) { + val steps = fieldDefaults?.get("thresholds")?.jsonObject?.get("steps")?.jsonArray + if (steps == null || steps.isEmpty()) return + + val moneatThresholds = steps.mapNotNull { step -> + val stepObj = step.jsonObject + val valuePrim = stepObj["value"]?.jsonPrimitive + val value = valuePrim?.intOrNull + ?: if (valuePrim?.contentOrNull == null) 0 else return@mapNotNull null + val color = stepObj["color"]?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null + buildJsonObject { + put("value", value) + put("color", color) } } + if (moneatThresholds.isNotEmpty()) { + config["thresholds"] = JsonArray(moneatThresholds).toString() + } + } - // Value mappings from fieldConfig.defaults.mappings - fieldDefaults?.get("mappings")?.jsonArray?.let { mappings -> - val moneatMappings = mappings.mapNotNull { mapping -> - val mapObj = mapping.jsonObject - val type = mapObj["type"]?.jsonPrimitive?.contentOrNull - when (type) { - "special" -> { - val opts = mapObj["options"]?.jsonObject ?: return@mapNotNull null - val match = opts["match"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null - val text = opts["result"]?.jsonObject?.get("text")?.jsonPrimitive?.contentOrNull - ?: return@mapNotNull null - val color = opts["result"]?.jsonObject?.get("color")?.jsonPrimitive?.contentOrNull - buildJsonObject { - put("value", match) - put("text", text) - color?.let { put("color", it) } - } - } - "value" -> { - val opts = mapObj["options"]?.jsonObject ?: return@mapNotNull null - val firstEntry = opts.entries.firstOrNull() ?: return@mapNotNull null - val key = firstEntry.key - val entry = firstEntry.value - val result = entry.jsonObject["result"]?.jsonObject ?: return@mapNotNull null - val text = result["text"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null - val color = result["color"]?.jsonPrimitive?.contentOrNull - buildJsonObject { - put("value", key) - put("text", text) - color?.let { put("color", it) } - } - } - else -> null - } - } - if (moneatMappings.isNotEmpty()) { - config["valueMappings"] = JsonArray(moneatMappings).toString() - } + private fun grafanaValueMappingToJson(mapObj: JsonObject): JsonObject? { + val type = mapObj["type"]?.jsonPrimitive?.contentOrNull + return when (type) { + "special" -> grafanaSpecialValueMapping(mapObj) + "value" -> grafanaDiscreteValueMapping(mapObj) + else -> null } + } - // Draw style and line width from custom config - defaults?.get("drawStyle")?.jsonPrimitive?.contentOrNull?.let { config["drawStyle"] = it } - defaults?.get("lineWidth")?.jsonPrimitive?.intOrNull?.let { config["lineWidth"] = it.toString() } + private fun grafanaSpecialValueMapping(mapObj: JsonObject): JsonObject? { + val opts = mapObj["options"]?.jsonObject ?: return null + val match = opts["match"]?.jsonPrimitive?.contentOrNull ?: return null + val result = opts["result"]?.jsonObject ?: return null + val text = result["text"]?.jsonPrimitive?.contentOrNull ?: return null + val color = result["color"]?.jsonPrimitive?.contentOrNull + return buildJsonObject { + put("value", match) + put("text", text) + if (color != null) put("color", color) + } + } - // fillOpacity: Grafana uses 0-100 scale, Moneat uses 0-1 - defaults?.get("fillOpacity")?.jsonPrimitive?.intOrNull?.let { - config["fillOpacity"] = (it / 100.0).toString() + private fun grafanaDiscreteValueMapping(mapObj: JsonObject): JsonObject? { + val opts = mapObj["options"]?.jsonObject ?: return null + val firstEntry = opts.entries.firstOrNull() ?: return null + val key = firstEntry.key + val result = firstEntry.value.jsonObject["result"]?.jsonObject ?: return null + val text = result["text"]?.jsonPrimitive?.contentOrNull ?: return null + val color = result["color"]?.jsonPrimitive?.contentOrNull + return buildJsonObject { + put("value", key) + put("text", text) + if (color != null) put("color", color) } + } - // Stacking → stackMode (frontend key) - defaults?.get( - "stacking" - )?.jsonObject?.get("mode")?.jsonPrimitive?.contentOrNull?.let { config["stackMode"] = it } + private fun putValueMappingsFromFieldDefaults( + fieldDefaults: JsonObject?, + config: MutableMap + ) { + val mappings = fieldDefaults?.get("mappings")?.jsonArray ?: return + val moneatMappings = mappings.mapNotNull { grafanaValueMappingToJson(it.jsonObject) } + if (moneatMappings.isNotEmpty()) { + config["valueMappings"] = JsonArray(moneatMappings).toString() + } + } - defaults?.get( - "scaleDistribution" - )?.jsonObject?.get("type")?.jsonPrimitive?.contentOrNull?.let { config["scaleType"] = it } + private fun putCustomDisplayDefaults(defaults: JsonObject?, config: MutableMap) { + val drawStyle = defaults?.get("drawStyle")?.jsonPrimitive?.contentOrNull + if (drawStyle != null) config["drawStyle"] = drawStyle - val options = panelJson["options"]?.jsonObject - val legend = options?.get("legend")?.jsonObject - if (legend != null) { - legend["placement"]?.jsonPrimitive?.contentOrNull?.let { config["legendPlacement"] = it } - legend["displayMode"]?.jsonPrimitive?.contentOrNull?.let { mode -> - config["legendMode"] = when (mode) { - "hidden" -> "hidden" - "table" -> "table" - else -> "list" - } - } - } + val lineWidth = defaults?.get("lineWidth")?.jsonPrimitive?.intOrNull + if (lineWidth != null) config["lineWidth"] = lineWidth.toString() - // Gauge-specific: min/max range - fieldDefaults?.get("min")?.jsonPrimitive?.intOrNull?.let { config["gaugeMin"] = it.toString() } - fieldDefaults?.get("max")?.jsonPrimitive?.intOrNull?.let { config["gaugeMax"] = it.toString() } + val fillOpacity = defaults?.get("fillOpacity")?.jsonPrimitive?.intOrNull + if (fillOpacity != null) config["fillOpacity"] = (fillOpacity / 100.0).toString() - // Bar gauge: orientation and display mode - options?.get("orientation")?.jsonPrimitive?.contentOrNull?.let { - config["orientation"] = it - } - options?.get("displayMode")?.jsonPrimitive?.contentOrNull?.let { - config["displayMode"] = it - } + val stackMode = defaults?.get("stacking")?.jsonObject?.get("mode")?.jsonPrimitive?.contentOrNull + if (stackMode != null) config["stackMode"] = stackMode - // Extract field filters and renames from Grafana transformations - extractGrafanaTransformations(panelJson, config) + val scaleType = defaults?.get("scaleDistribution")?.jsonObject?.get("type") + ?.jsonPrimitive?.contentOrNull + if (scaleType != null) config["scaleType"] = scaleType + } - return config + private fun putLegendFromOptions(options: JsonObject?, config: MutableMap) { + val legend = options?.get("legend")?.jsonObject ?: return + val placement = legend["placement"]?.jsonPrimitive?.contentOrNull + if (placement != null) config["legendPlacement"] = placement + val mode = legend["displayMode"]?.jsonPrimitive?.contentOrNull ?: return + config["legendMode"] = when (mode) { + "hidden" -> "hidden" + "table" -> "table" + else -> "list" + } + } + + private fun putGaugeRangeAndBarGaugeOptions( + fieldDefaults: JsonObject?, + options: JsonObject?, + config: MutableMap + ) { + val min = fieldDefaults?.get("min")?.jsonPrimitive?.intOrNull + if (min != null) config["gaugeMin"] = min.toString() + val max = fieldDefaults?.get("max")?.jsonPrimitive?.intOrNull + if (max != null) config["gaugeMax"] = max.toString() + + val orientation = options?.get("orientation")?.jsonPrimitive?.contentOrNull + if (orientation != null) config["orientation"] = orientation + val displayMode = options?.get("displayMode")?.jsonPrimitive?.contentOrNull + if (displayMode != null) config["displayMode"] = displayMode } /** From 7a5edcf8b0532ef659fec5b6bea36f2d93c5d06f Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Thu, 2 Apr 2026 09:58:45 -0400 Subject: [PATCH 07/11] fix: address SonarQube issues (round 2) for #298 --- .../translation/GrafanaTranslator.kt | 77 ++++++++++--------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 8ae0a5b5..d2e74ec7 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -244,23 +244,28 @@ class GrafanaTranslator : DashboardTranslator { val steps = fieldDefaults?.get("thresholds")?.jsonObject?.get("steps")?.jsonArray if (steps == null || steps.isEmpty()) return - val moneatThresholds = steps.mapNotNull { step -> - val stepObj = step.jsonObject - val valuePrim = stepObj["value"]?.jsonPrimitive - val value = valuePrim?.intOrNull - ?: if (valuePrim?.contentOrNull == null) 0 else return@mapNotNull null - val color = stepObj["color"]?.jsonPrimitive?.contentOrNull - ?: return@mapNotNull null - buildJsonObject { - put("value", value) - put("color", color) - } - } + val moneatThresholds = steps.mapNotNull { thresholdStepToJsonObject(it) } if (moneatThresholds.isNotEmpty()) { config["thresholds"] = JsonArray(moneatThresholds).toString() } } + private fun thresholdStepToJsonObject(step: JsonElement): JsonObject? { + val stepObj = step.jsonObject + val valuePrim = stepObj["value"]?.jsonPrimitive + val intVal = valuePrim?.intOrNull + val value = when { + intVal != null -> intVal + valuePrim?.contentOrNull == null -> 0 + else -> return null + } + val color = stepObj["color"]?.jsonPrimitive?.contentOrNull ?: return null + return buildJsonObject { + put("value", value) + put("color", color) + } + } + private fun grafanaValueMappingToJson(mapObj: JsonObject): JsonObject? { val type = mapObj["type"]?.jsonPrimitive?.contentOrNull return when (type) { @@ -371,36 +376,32 @@ class GrafanaTranslator : DashboardTranslator { val opts = transform["options"]?.jsonObject ?: continue when (id) { - "filterFieldsByName" -> { - val include = opts["include"]?.jsonObject - val names = include?.get("names")?.jsonArray - if (names != null && names.isNotEmpty()) { - config["visibleFields"] = names.joinToString(",") { - it.jsonPrimitive.content - } - } - } - "organize" -> { - val renameByName = opts["renameByName"]?.jsonObject - if (renameByName != null && renameByName.isNotEmpty()) { - val renames = renameByName.entries - .filter { - it.value.jsonPrimitive.contentOrNull?.isNotEmpty() == true - } - .associate { - it.key to it.value.jsonPrimitive.content - } - if (renames.isNotEmpty()) { - config["fieldRenames"] = buildJsonObject { - renames.forEach { (k, v) -> put(k, v) } - }.toString() - } - } - } + "filterFieldsByName" -> applyFilterFieldsByNameTransform(opts, config) + "organize" -> applyOrganizeTransform(opts, config) } } } + private fun applyFilterFieldsByNameTransform(opts: JsonObject, config: MutableMap) { + val names = opts["include"]?.jsonObject?.get("names")?.jsonArray + if (names == null || names.isEmpty()) return + config["visibleFields"] = names.joinToString(",") { it.jsonPrimitive.content } + } + + private fun applyOrganizeTransform(opts: JsonObject, config: MutableMap) { + val renameByName = opts["renameByName"]?.jsonObject ?: return + if (renameByName.isEmpty()) return + + val renames = renameByName.entries + .filter { it.value.jsonPrimitive.contentOrNull?.isNotEmpty() == true } + .associate { it.key to it.value.jsonPrimitive.content } + if (renames.isEmpty()) return + + config["fieldRenames"] = buildJsonObject { + renames.forEach { (k, v) -> put(k, v) } + }.toString() + } + internal fun parseGrafanaTargets( panelJson: JsonObject, warnings: MutableList, From 3f5e1cb998559049d31f7210b226ed5862dab735 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Fri, 3 Apr 2026 09:34:50 -0400 Subject: [PATCH 08/11] fix: address SonarQube issues (round 1) for #298 --- .../translation/GrafanaTranslator.kt | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index d2e74ec7..80b4c045 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -212,6 +212,12 @@ class GrafanaTranslator : DashboardTranslator { /** Builds Moneat display config key/value strings from Grafana panel fieldConfig, mappings, and options. */ private fun extractDisplayConfig(panelJson: JsonObject): Map { val config = mutableMapOf() + populateDisplayConfigFromPanel(panelJson, config) + extractGrafanaTransformations(panelJson, config) + return config + } + + private fun populateDisplayConfigFromPanel(panelJson: JsonObject, config: MutableMap) { val fieldDefaults = panelJson["fieldConfig"]?.jsonObject?.get("defaults")?.jsonObject val defaults = fieldDefaults?.get("custom")?.jsonObject val options = panelJson["options"]?.jsonObject @@ -222,9 +228,6 @@ class GrafanaTranslator : DashboardTranslator { putCustomDisplayDefaults(defaults, config) putLegendFromOptions(options, config) putGaugeRangeAndBarGaugeOptions(fieldDefaults, options, config) - extractGrafanaTransformations(panelJson, config) - - return config } private fun putFieldDefaultUnitAndDecimals( @@ -242,12 +245,14 @@ class GrafanaTranslator : DashboardTranslator { config: MutableMap ) { val steps = fieldDefaults?.get("thresholds")?.jsonObject?.get("steps")?.jsonArray - if (steps == null || steps.isEmpty()) return + if (steps.isNullOrEmpty()) return + applyParsedThresholdSteps(steps, config) + } + private fun applyParsedThresholdSteps(steps: JsonArray, config: MutableMap) { val moneatThresholds = steps.mapNotNull { thresholdStepToJsonObject(it) } - if (moneatThresholds.isNotEmpty()) { - config["thresholds"] = JsonArray(moneatThresholds).toString() - } + if (moneatThresholds.isEmpty()) return + config["thresholds"] = JsonArray(moneatThresholds).toString() } private fun thresholdStepToJsonObject(step: JsonElement): JsonObject? { @@ -307,10 +312,13 @@ class GrafanaTranslator : DashboardTranslator { config: MutableMap ) { val mappings = fieldDefaults?.get("mappings")?.jsonArray ?: return + applyParsedValueMappings(mappings, config) + } + + private fun applyParsedValueMappings(mappings: JsonArray, config: MutableMap) { val moneatMappings = mappings.mapNotNull { grafanaValueMappingToJson(it.jsonObject) } - if (moneatMappings.isNotEmpty()) { - config["valueMappings"] = JsonArray(moneatMappings).toString() - } + if (moneatMappings.isEmpty()) return + config["valueMappings"] = JsonArray(moneatMappings).toString() } private fun putCustomDisplayDefaults(defaults: JsonObject?, config: MutableMap) { From 8529906bff52a238925a87b07a1bc415a36c479a Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Fri, 3 Apr 2026 09:41:56 -0400 Subject: [PATCH 09/11] fix: address SonarQube issues (round 2) for #298 --- .../com/moneat/dashboards/translation/GrafanaTranslator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 80b4c045..1a70e024 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -28,6 +28,7 @@ import com.moneat.dashboards.models.GroupByType import com.moneat.dashboards.models.MetricDef import com.moneat.dashboards.models.QueryDsl import com.moneat.dashboards.models.WidgetResponse +import com.moneat.utils.suspendRunCatching import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -42,7 +43,6 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import mu.KotlinLogging import kotlin.math.roundToInt -import com.moneat.utils.suspendRunCatching private val logger = KotlinLogging.logger {} From b710d89457da45f0b544a70c9d47595c6dd72c92 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Fri, 3 Apr 2026 10:02:40 -0400 Subject: [PATCH 10/11] fix: address SonarQube issues (round 4) for #298 --- .../translation/GrafanaTranslator.kt | 278 ++++++++++-------- 1 file changed, 156 insertions(+), 122 deletions(-) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index 1a70e024..b8bf17a5 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -473,94 +473,122 @@ class GrafanaTranslator : DashboardTranslator { panelIndex: Int, legendFormat: String? ): QueryDsl { - // Try PromQL first (takes priority — rawSql may be a Grafana default template) + tryParsePromqlGrafanaTarget(target, warnings, panelIndex, legendFormat)?.let { return it } + tryParseSqlGrafanaTarget(target, warnings, panelIndex)?.let { return it } + tryParseElasticsearchGrafanaTarget(target, legendFormat)?.let { return it } + tryParseGenericQueryGrafanaTarget(target, warnings, panelIndex, legendFormat)?.let { return it } + tryParseRedisGrafanaTarget(target, legendFormat)?.let { return it } + tryParseInfluxGrafanaTarget(target, legendFormat)?.let { return it } + tryParseCloudWatchGrafanaTarget(target, legendFormat)?.let { return it } + tryParseGraphiteGrafanaTarget(target, legendFormat)?.let { return it } + return parseGrafanaTargetFallback(target, warnings, panelIndex, legendFormat) + } + + /** PromQL takes priority — rawSql may be a Grafana default template. */ + private fun tryParsePromqlGrafanaTarget( + target: JsonObject, + warnings: MutableList, + panelIndex: Int, + legendFormat: String? + ): QueryDsl? { val expr = target["expr"]?.jsonPrimitive?.contentOrNull - if (!expr.isNullOrBlank()) { - val parsed = parsePromQL(expr, warnings, panelIndex) - // Apply legendFormat as the metric alias so the chart uses it as series name - return if (legendFormat != null && parsed.metrics.isNotEmpty()) { - parsed.copy(metrics = parsed.metrics.map { it.copy(alias = legendFormat) }) - } else { - parsed - } + if (expr.isNullOrBlank()) return null + val parsed = parsePromQL(expr, warnings, panelIndex) + return if (legendFormat != null && parsed.metrics.isNotEmpty()) { + parsed.copy(metrics = parsed.metrics.map { it.copy(alias = legendFormat) }) + } else { + parsed } + } - // Try SQL - val rawSql = target["rawSql"]?.jsonPrimitive?.contentOrNull - if (rawSql != null) { - return parseGrafanaSql(rawSql, warnings, panelIndex) - } + private fun tryParseSqlGrafanaTarget( + target: JsonObject, + warnings: MutableList, + panelIndex: Int + ): QueryDsl? { + val rawSql = target["rawSql"]?.jsonPrimitive?.contentOrNull ?: return null + return parseGrafanaSql(rawSql, warnings, panelIndex) + } - // Try Grafana Elasticsearch plugin format (metrics/bucketAggs arrays) - // Check before generic query so ES targets with Lucene query + aggregations - // get full translation instead of losing metrics/bucketAggs + /** ES targets with Lucene query + aggregations — before generic [query] handling. */ + private fun tryParseElasticsearchGrafanaTarget( + target: JsonObject, + legendFormat: String? + ): QueryDsl? { val esMetrics = target["metrics"] as? JsonArray val esBucketAggs = target["bucketAggs"] as? JsonArray - if (esMetrics != null || esBucketAggs != null) { - val esQuery = translateGrafanaElasticsearchTarget(target) - return QueryDsl( - dataSource = "events", - metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), - rawQuery = esQuery - ) - } + if (esMetrics == null && esBucketAggs == null) return null + val esQuery = translateGrafanaElasticsearchTarget(target) + return QueryDsl( + dataSource = "events", + metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), + rawQuery = esQuery + ) + } - // Try generic query (skip empty strings from plugin-specific targets) + private fun tryParseGenericQueryGrafanaTarget( + target: JsonObject, + warnings: MutableList, + panelIndex: Int, + legendFormat: String? + ): QueryDsl? { val query = target["query"]?.jsonPrimitive?.contentOrNull - if (!query.isNullOrBlank()) { - warnings.add("Panel $panelIndex: generic query stored as rawQuery") - return QueryDsl( - dataSource = "events", - metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), - rawQuery = query - ) - } + if (query.isNullOrBlank()) return null + warnings.add("Panel $panelIndex: generic query stored as rawQuery") + return QueryDsl( + dataSource = "events", + metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), + rawQuery = query + ) + } - // Try Grafana Redis datasource plugin format (command/section/type fields) - val redisCommand = target["command"]?.jsonPrimitive?.contentOrNull - if (redisCommand != null) { - val rawCmd = translateGrafanaRedisCommand(redisCommand, target) - return QueryDsl( - dataSource = "events", - metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), - rawQuery = rawCmd - ) - } + private fun tryParseRedisGrafanaTarget(target: JsonObject, legendFormat: String?): QueryDsl? { + val redisCommand = target["command"]?.jsonPrimitive?.contentOrNull ?: return null + val rawCmd = translateGrafanaRedisCommand(redisCommand, target) + return QueryDsl( + dataSource = "events", + metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), + rawQuery = rawCmd + ) + } - // Try Grafana InfluxDB plugin format (measurement/select/groupBy) - val measurement = target["measurement"]?.jsonPrimitive?.contentOrNull - if (measurement != null) { - val fluxFilter = translateGrafanaInfluxTarget(target) - return QueryDsl( - dataSource = "events", - metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), - rawQuery = fluxFilter - ) - } + private fun tryParseInfluxGrafanaTarget(target: JsonObject, legendFormat: String?): QueryDsl? { + if (target["measurement"]?.jsonPrimitive?.contentOrNull == null) return null + val fluxFilter = translateGrafanaInfluxTarget(target) + return QueryDsl( + dataSource = "events", + metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), + rawQuery = fluxFilter + ) + } - // Try Grafana CloudWatch plugin format (namespace/metricName) - val namespace = target["namespace"]?.jsonPrimitive?.contentOrNull - if (namespace != null && target.containsKey("metricName")) { - val cwJson = translateGrafanaCloudWatchTarget(target) - return QueryDsl( - dataSource = "events", - metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), - rawQuery = cwJson - ) - } + private fun tryParseCloudWatchGrafanaTarget(target: JsonObject, legendFormat: String?): QueryDsl? { + val namespace = target["namespace"]?.jsonPrimitive?.contentOrNull ?: return null + if (!target.containsKey("metricName")) return null + val cwJson = translateGrafanaCloudWatchTarget(target) + return QueryDsl( + dataSource = "events", + metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), + rawQuery = cwJson + ) + } - // Try Grafana Graphite plugin format (target field) + private fun tryParseGraphiteGrafanaTarget(target: JsonObject, legendFormat: String?): QueryDsl? { val graphiteTarget = target["target"]?.jsonPrimitive?.contentOrNull - if (!graphiteTarget.isNullOrBlank()) { - return QueryDsl( - dataSource = "events", - metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), - rawQuery = graphiteTarget - ) - } + if (graphiteTarget.isNullOrBlank()) return null + return QueryDsl( + dataSource = "events", + metrics = listOf(MetricDef(AggFunction.COUNT, alias = legendFormat ?: "count")), + rawQuery = graphiteTarget + ) + } - // No standard query format found — serialize the full target as rawQuery - // so plugin-specific fields are preserved + private fun parseGrafanaTargetFallback( + target: JsonObject, + warnings: MutableList, + panelIndex: Int, + legendFormat: String? + ): QueryDsl { val knownKeys = setOf("refId", "datasource", "legendFormat", "query") val hasExtraFields = target.keys.any { it !in knownKeys } if (hasExtraFields) { @@ -573,7 +601,6 @@ class GrafanaTranslator : DashboardTranslator { rawQuery = target.toString() ) } - warnings.add("Panel $panelIndex: no recognizable query target") return QueryDsl( dataSource = "events", @@ -841,54 +868,18 @@ class GrafanaTranslator : DashboardTranslator { // Try aggregation with by/without: sum by (labels) (inner_expr) val aggByMatch = Regex("""^(\w+)\s+(?:by|without)\s*\(([^)]*)\)\s*\((.+)\)$""").find(normalized) - val (metricName, labelStr, aggFunction) = when { - aggByMatch != null -> { - // Parse inner expression recursively for metric name - val innerExpr = aggByMatch.groupValues[3].trim() - val innerFunc = Regex("""(\w+)\(([^{(]+?)(?:\{[^}]*\})?(?:\[[^]]*])?.*\)""").find(innerExpr) - val metric = innerFunc?.groupValues?.getOrNull(2)?.trim() ?: "unknown" - val innerLabels = Regex("""\{([^}]*)\}""").find(innerExpr)?.groupValues?.get(1) ?: "" - Triple(metric, innerLabels, mapPromFunction(aggByMatch.groupValues[1])) - } - funcMatch != null -> { - Triple( - funcMatch.groupValues[2].trim(), - funcMatch.groupValues[3], - mapPromFunction(funcMatch.groupValues[1]) - ) - } - bareMatch != null -> { - Triple( - bareMatch.groupValues[1].trim(), - bareMatch.groupValues[2], - AggFunction.AVG - ) - } - else -> { - warnings.add("Panel $panelIndex: couldn't parse PromQL '$expr', stored as rawQuery") - return QueryDsl( - dataSource = "__prometheus", - metrics = listOf(MetricDef(AggFunction.AVG, alias = "value")), - rawQuery = expr, - limit = 5000 - ) - } - } - - val filters = mutableListOf() - if (labelStr.isNotBlank()) { - // Parse label matchers: key="val", key=~"val", key!="val", key!~"val" - Regex("""(\w+)\s*(=~|!=|!~|=)\s*"([^"]*?)"""").findAll(labelStr).forEach { m -> - val key = m.groupValues[1] - val op = when (m.groupValues[2]) { - "=~" -> FilterOp.LIKE - "!~" -> FilterOp.NOT_LIKE - "!=" -> FilterOp.NEQ - else -> FilterOp.EQ - } - filters.add(FilterDef(key, op, m.groupValues[3])) - } + val parsedMetric = resolvePromqlMetricAndLabels(aggByMatch, funcMatch, bareMatch) + if (parsedMetric == null) { + warnings.add("Panel $panelIndex: couldn't parse PromQL '$expr', stored as rawQuery") + return QueryDsl( + dataSource = "__prometheus", + metrics = listOf(MetricDef(AggFunction.AVG, alias = "value")), + rawQuery = expr, + limit = 5000 + ) } + val (metricName, labelStr, aggFunction) = parsedMetric + val filters = parsePromqlLabelMatchers(labelStr) return QueryDsl( dataSource = "__prometheus", @@ -900,6 +891,49 @@ class GrafanaTranslator : DashboardTranslator { ) } + private fun resolvePromqlMetricAndLabels( + aggByMatch: MatchResult?, + funcMatch: MatchResult?, + bareMatch: MatchResult? + ): Triple? = when { + aggByMatch != null -> { + val innerExpr = aggByMatch.groupValues[3].trim() + val innerFunc = Regex("""(\w+)\(([^{(]+?)(?:\{[^}]*\})?(?:\[[^]]*])?.*\)""").find(innerExpr) + val metric = innerFunc?.groupValues?.getOrNull(2)?.trim() ?: "unknown" + val innerLabels = Regex("""\{([^}]*)\}""").find(innerExpr)?.groupValues?.get(1) ?: "" + Triple(metric, innerLabels, mapPromFunction(aggByMatch.groupValues[1])) + } + funcMatch != null -> { + Triple( + funcMatch.groupValues[2].trim(), + funcMatch.groupValues[3], + mapPromFunction(funcMatch.groupValues[1]) + ) + } + bareMatch != null -> { + Triple( + bareMatch.groupValues[1].trim(), + bareMatch.groupValues[2], + AggFunction.AVG + ) + } + else -> null + } + + private fun parsePromqlLabelMatchers(labelStr: String): List { + if (labelStr.isBlank()) return emptyList() + val labelRegex = Regex("""(\w+)\s*(=~|!=|!~|=)\s*"([^"]*?)"""") + return labelRegex.findAll(labelStr).map { m -> + val op = when (m.groupValues[2]) { + "=~" -> FilterOp.LIKE + "!~" -> FilterOp.NOT_LIKE + "!=" -> FilterOp.NEQ + else -> FilterOp.EQ + } + FilterDef(m.groupValues[1], op, m.groupValues[3]) + }.toList() + } + private fun mapPromFunction(fn: String): AggFunction = when (fn.lowercase()) { "rate", "irate", "increase" -> AggFunction.AVG "sum" -> AggFunction.SUM From 338939e9207d2884e4789fb44fcafa00e213b686 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Fri, 3 Apr 2026 10:08:59 -0400 Subject: [PATCH 11/11] fix: address SonarQube issues (round 5) for #298 --- .../translation/GrafanaTranslator.kt | 122 +++++++++--------- 1 file changed, 63 insertions(+), 59 deletions(-) diff --git a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt index b8bf17a5..55a7a70d 100644 --- a/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt +++ b/backend/src/main/kotlin/com/moneat/dashboards/translation/GrafanaTranslator.kt @@ -703,30 +703,32 @@ class GrafanaTranslator : DashboardTranslator { } val dims = target["dimensions"] if (dims is JsonObject) { - put( - "Dimensions", - buildJsonArray { - for ((key, value) in dims) { - val vals = if (value is JsonArray) { - value.map { it.jsonPrimitive.content } - } else { - listOf(value.jsonPrimitive.content) - } - for (v in vals) { - add( - buildJsonObject { - put("Name", key) - put("Value", v) - } - ) - } - } + put("Dimensions", buildCloudWatchDimensionsArray(dims)) + } + }.toString() + } + + private fun buildCloudWatchDimensionsArray(dims: JsonObject): JsonArray = buildJsonArray { + for ((key, value) in dims) { + val vals = cloudWatchDimensionValueStrings(value) + for (v in vals) { + add( + buildJsonObject { + put("Name", key) + put("Value", v) } ) } - }.toString() + } } + private fun cloudWatchDimensionValueStrings(value: JsonElement): List = + if (value is JsonArray) { + value.map { it.jsonPrimitive.content } + } else { + listOf(value.jsonPrimitive.content) + } + /** * Convert Grafana Elasticsearch plugin target to an ES query JSON body * from the metrics/bucketAggs arrays + Lucene query filter. @@ -979,46 +981,7 @@ class GrafanaTranslator : DashboardTranslator { return list.mapNotNull { element -> suspendRunCatching { - val varObj = element.jsonObject - val name = varObj["name"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null - val type = varObj["type"]?.jsonPrimitive?.contentOrNull ?: "custom" - val label = varObj["label"]?.jsonPrimitive?.contentOrNull - val query = varObj["query"]?.let { q -> - when (q) { - is JsonPrimitive -> q.contentOrNull - is JsonObject -> q["query"]?.jsonPrimitive?.contentOrNull - else -> null - } - } - val current = varObj["current"]?.jsonObject?.get("value")?.let { v -> - when (v) { - is JsonPrimitive -> v.contentOrNull - else -> null - } - } - val options = varObj["options"]?.jsonArray?.mapNotNull { opt -> - opt.jsonObject["value"]?.jsonPrimitive?.contentOrNull - } ?: emptyList() - - val datasource = varObj["datasource"]?.let { ds -> - when (ds) { - is JsonPrimitive -> ds.contentOrNull - is JsonObject -> ds["type"]?.jsonPrimitive?.contentOrNull - else -> null - } - } - - val supportedTypes = setOf("query", "custom", "textbox", "constant") - DashboardVariable( - name = name, - label = label, - type = if (type in supportedTypes) type else "custom", - query = query, - defaultValue = current, - current = current, - options = options.filter { it != "\$__all" }, - datasource = datasource - ) + parseSingleGrafanaVariable(element.jsonObject) }.getOrElse { e -> logger.warn { "Failed to parse Grafana variable: ${e.message}" } null @@ -1026,6 +989,47 @@ class GrafanaTranslator : DashboardTranslator { } } + private fun parseSingleGrafanaVariable(varObj: JsonObject): DashboardVariable? { + val name = varObj["name"]?.jsonPrimitive?.contentOrNull ?: return null + val type = varObj["type"]?.jsonPrimitive?.contentOrNull ?: "custom" + val label = varObj["label"]?.jsonPrimitive?.contentOrNull + val query = varObj["query"]?.let(::grafanaVariableQueryFromJson) + val current = varObj["current"]?.jsonObject?.get("value")?.let(::grafanaVariableCurrentString) + val options = varObj["options"]?.jsonArray?.mapNotNull { opt -> + opt.jsonObject["value"]?.jsonPrimitive?.contentOrNull + } ?: emptyList() + val datasource = varObj["datasource"]?.let(::grafanaVariableDatasourceFromJson) + + val supportedTypes = setOf("query", "custom", "textbox", "constant") + return DashboardVariable( + name = name, + label = label, + type = if (type in supportedTypes) type else "custom", + query = query, + defaultValue = current, + current = current, + options = options.filter { it != "\$__all" }, + datasource = datasource + ) + } + + private fun grafanaVariableQueryFromJson(q: JsonElement): String? = when (q) { + is JsonPrimitive -> q.contentOrNull + is JsonObject -> q["query"]?.jsonPrimitive?.contentOrNull + else -> null + } + + private fun grafanaVariableCurrentString(v: JsonElement): String? = when (v) { + is JsonPrimitive -> v.contentOrNull + else -> null + } + + private fun grafanaVariableDatasourceFromJson(ds: JsonElement): String? = when (ds) { + is JsonPrimitive -> ds.contentOrNull + is JsonObject -> ds["type"]?.jsonPrimitive?.contentOrNull + else -> null + } + override fun export(dashboard: DashboardResponse): JsonObject { val panels = dashboard.widgets.mapIndexed { index, widget -> buildJsonObject {