This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-23862 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 2d23b9ba0a288ccb46671325ee3c82d2fd647f02 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Jun 30 15:01:12 2026 +0200 CAMEL-23862: Add SQL Trace dev console, TUI tab, and CLI command Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/dev-consoles.properties | 1 + .../camel/catalog/dev-consoles/sql-trace.json | 15 + .../impl/console/SqlTraceDevConsoleConfigurer.java | 67 ++++ .../org/apache/camel/dev-console/sql-trace.json | 15 + ...rg.apache.camel.impl.console.SqlTraceDevConsole | 2 + .../org/apache/camel/dev-console/sql-trace | 2 + .../org/apache/camel/dev-consoles.properties | 2 +- .../camel/impl/console/SqlTraceDevConsole.java | 284 +++++++++++++++++ .../jbang-commands/camel-jbang-get-sql-trace.adoc | 28 ++ .../ROOT/pages/jbang-commands/camel-jbang-get.adoc | 1 + .../camel/cli/connector/LocalCliConnector.java | 7 + .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../dsl/jbang/core/commands/CamelJBangMain.java | 1 + .../jbang/core/commands/process/ListSqlTrace.java | 211 +++++++++++++ .../jbang/core/commands/tui/IntegrationInfo.java | 6 + .../dsl/jbang/core/commands/tui/McpFacade.java | 4 +- .../dsl/jbang/core/commands/tui/PopupManager.java | 16 +- .../dsl/jbang/core/commands/tui/SqlTraceInfo.java | 30 ++ .../dsl/jbang/core/commands/tui/SqlTraceTab.java | 336 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/StatusParser.java | 31 ++ .../dsl/jbang/core/commands/tui/TabRegistry.java | 12 +- .../core/commands/tui/SqlTraceTabRenderTest.java | 196 ++++++++++++ 22 files changed, 1255 insertions(+), 14 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties index 217aa1876741..aff1673d5525 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties @@ -57,6 +57,7 @@ sftp simple-language source sql-query +sql-trace startup-recorder stub system-properties diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/sql-trace.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/sql-trace.json new file mode 100644 index 000000000000..55335065a456 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/sql-trace.json @@ -0,0 +1,15 @@ +{ + "console": { + "kind": "console", + "group": "camel", + "name": "sql-trace", + "title": "SQL Trace", + "description": "Trace SQL query executions", + "deprecated": false, + "javaType": "org.apache.camel.impl.console.SqlTraceDevConsole", + "groupId": "org.apache.camel", + "artifactId": "camel-console", + "version": "4.21.0-SNAPSHOT" + } +} + diff --git a/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlTraceDevConsoleConfigurer.java b/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlTraceDevConsoleConfigurer.java new file mode 100644 index 000000000000..0948021b78e5 --- /dev/null +++ b/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlTraceDevConsoleConfigurer.java @@ -0,0 +1,67 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.impl.console; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.impl.console.SqlTraceDevConsole; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo") +@SuppressWarnings("unchecked") +public class SqlTraceDevConsoleConfigurer extends org.apache.camel.support.component.PropertyConfigurerSupport implements GeneratedPropertyConfigurer, ExtendedPropertyConfigurerGetter { + + private static final Map<String, Object> ALL_OPTIONS; + static { + Map<String, Object> map = new CaseInsensitiveMap(); + map.put("CamelContext", org.apache.camel.CamelContext.class); + map.put("Capacity", int.class); + ALL_OPTIONS = map; + } + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + org.apache.camel.impl.console.SqlTraceDevConsole target = (org.apache.camel.impl.console.SqlTraceDevConsole) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": target.setCamelContext(property(camelContext, org.apache.camel.CamelContext.class, value)); return true; + case "capacity": target.setCapacity(property(camelContext, int.class, value)); return true; + default: return false; + } + } + + @Override + public Map<String, Object> getAllOptions(Object target) { + return ALL_OPTIONS; + } + + @Override + public Class<?> getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": return org.apache.camel.CamelContext.class; + case "capacity": return int.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + org.apache.camel.impl.console.SqlTraceDevConsole target = (org.apache.camel.impl.console.SqlTraceDevConsole) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": return target.getCamelContext(); + case "capacity": return target.getCapacity(); + default: return null; + } + } +} + diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-trace.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-trace.json new file mode 100644 index 000000000000..55335065a456 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-trace.json @@ -0,0 +1,15 @@ +{ + "console": { + "kind": "console", + "group": "camel", + "name": "sql-trace", + "title": "SQL Trace", + "description": "Trace SQL query executions", + "deprecated": false, + "javaType": "org.apache.camel.impl.console.SqlTraceDevConsole", + "groupId": "org.apache.camel", + "artifactId": "camel-console", + "version": "4.21.0-SNAPSHOT" + } +} + diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlTraceDevConsole b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlTraceDevConsole new file mode 100644 index 000000000000..41852b612ce6 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlTraceDevConsole @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.SqlTraceDevConsoleConfigurer diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-trace b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-trace new file mode 100644 index 000000000000..4d0f5e4e974b --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-trace @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.SqlTraceDevConsole diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties index 40b26b866b52..fe5d76fe5112 100644 --- a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties @@ -1,5 +1,5 @@ # Generated by camel build tools - do NOT edit this file! -dev-consoles=bean blocked browse circuit-breaker consumer context datasource debug endpoint errors eval-language event gc health inflight internal-tasks java-security jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source sql-query startup-recorder system-properties thread top trace transformers type-converters variables +dev-consoles=bean blocked browse circuit-breaker consumer context datasource debug endpoint errors eval-language event gc health inflight internal-tasks java-security jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source sql-query sql-trace startup-recorder system-properties thread top trace transformers type-converters variables groupId=org.apache.camel artifactId=camel-console version=4.21.0-SNAPSHOT diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlTraceDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlTraceDevConsole.java new file mode 100644 index 000000000000..cd5841af616a --- /dev/null +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlTraceDevConsole.java @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.impl.console; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.Exchange; +import org.apache.camel.NonManagedService; +import org.apache.camel.spi.CamelEvent; +import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.annotations.DevConsole; +import org.apache.camel.support.EventNotifierSupport; +import org.apache.camel.support.console.AbstractDevConsole; +import org.apache.camel.util.StringHelper; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +@DevConsole(name = "sql-trace", displayName = "SQL Trace", description = "Trace SQL query executions") +@Configurer(extended = true) +public class SqlTraceDevConsole extends AbstractDevConsole { + + @Metadata(defaultValue = "200", + description = "Maximum capacity of traced SQL statements (capacity must be between 25 and 1000)") + private int capacity = 200; + + private JsonObject[] events; + private final AtomicInteger pos = new AtomicInteger(); + private final ConsoleEventNotifier listener = new ConsoleEventNotifier(); + + public SqlTraceDevConsole() { + super("camel", "sql-trace", "SQL Trace", "Trace SQL query executions"); + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } + + @Override + protected void doInit() throws Exception { + if (capacity > 1000 || capacity < 25) { + throw new IllegalArgumentException("Capacity must be between 25 and 1000"); + } + this.events = new JsonObject[capacity]; + } + + @Override + protected void doStart() throws Exception { + getCamelContext().getManagementStrategy().addEventNotifier(listener); + } + + @Override + protected void doStop() throws Exception { + getCamelContext().getManagementStrategy().removeEventNotifier(listener); + } + + @Override + protected String doCallText(Map<String, Object> options) { + StringBuilder sb = new StringBuilder(); + + List<JsonObject> list = collectEvents(); + for (JsonObject jo : list) { + sb.append(String.format(" %s %s %s (%d ms) route:%s%n", + jo.getString("category"), + jo.getString("query"), + jo.getBooleanOrDefault("failed", false) ? "FAILED" : "OK", + jo.getLongOrDefault("duration", 0), + jo.getString("routeId"))); + } + if (!list.isEmpty()) { + sb.insert(0, String.format("Last %d SQL Statements:%n", list.size())); + } + + return sb.toString(); + } + + @Override + protected JsonObject doCallJson(Map<String, Object> options) { + JsonObject root = new JsonObject(); + + List<JsonObject> list = collectEvents(); + if (!list.isEmpty()) { + JsonArray arr = new JsonArray(); + arr.addAll(list); + root.put("statements", arr); + + // compute summary + JsonObject summary = new JsonObject(); + long total = list.size(); + long totalTime = 0; + long slowest = 0; + long slowCount = 0; + long failedCount = 0; + long selectCount = 0; + long insertCount = 0; + long updateCount = 0; + long deleteCount = 0; + + for (JsonObject jo : list) { + long duration = jo.getLongOrDefault("duration", 0); + totalTime += duration; + if (duration > slowest) { + slowest = duration; + } + if (duration >= 100) { + slowCount++; + } + if (jo.getBooleanOrDefault("failed", false)) { + failedCount++; + } + String cat = jo.getStringOrDefault("category", ""); + switch (cat) { + case "SELECT": + selectCount++; + break; + case "INSERT": + insertCount++; + break; + case "UPDATE": + updateCount++; + break; + case "DELETE": + deleteCount++; + break; + default: + break; + } + } + + summary.put("totalQueries", total); + summary.put("avgTime", total > 0 ? totalTime / total : 0); + summary.put("slowestTime", slowest); + summary.put("slowCount", slowCount); + summary.put("failedCount", failedCount); + summary.put("selectCount", selectCount); + summary.put("insertCount", insertCount); + summary.put("updateCount", updateCount); + summary.put("deleteCount", deleteCount); + root.put("summary", summary); + } + + return root; + } + + private List<JsonObject> collectEvents() { + List<JsonObject> list = new ArrayList<>(); + int cursor = pos.get(); + // cursor points to the NEXT write slot, so walk backward from cursor-1 + for (int i = 0; i < capacity; i++) { + cursor = (cursor - 1 + capacity) % capacity; + JsonObject event = events[cursor]; + if (event != null) { + list.add(event); + } + } + return list; + } + + private static String extractQuery(String endpointUri) { + if (endpointUri.startsWith("sql:")) { + String query = StringHelper.after(endpointUri, "sql:"); + if (query != null) { + // remove query parameters + int idx = query.indexOf('?'); + if (idx > 0) { + query = query.substring(0, idx); + } + return query; + } + } else if (endpointUri.startsWith("jdbc:")) { + // for jdbc component, the URI path is the datasource name, not the SQL query + return null; + } + return null; + } + + private static String detectCategory(String query) { + if (query != null && !query.isEmpty()) { + String upper = query.stripLeading().toUpperCase(Locale.ENGLISH); + if (upper.startsWith("SELECT")) { + return "SELECT"; + } else if (upper.startsWith("INSERT")) { + return "INSERT"; + } else if (upper.startsWith("UPDATE")) { + return "UPDATE"; + } else if (upper.startsWith("DELETE")) { + return "DELETE"; + } else if (upper.startsWith("CALL") || upper.startsWith("EXEC")) { + return "CALL"; + } + } + return "OTHER"; + } + + private class ConsoleEventNotifier extends EventNotifierSupport implements NonManagedService { + + ConsoleEventNotifier() { + setIgnoreCamelContextEvents(true); + setIgnoreRouteEvents(true); + setIgnoreServiceEvents(true); + setIgnoreExchangeCreatedEvent(true); + setIgnoreExchangeCompletedEvent(true); + setIgnoreExchangeFailedEvents(true); + setIgnoreExchangeRedeliveryEvents(true); + setIgnoreExchangeSendingEvents(true); + setIgnoreStepEvents(true); + } + + @Override + public void notify(CamelEvent event) throws Exception { + if (event instanceof CamelEvent.ExchangeSentEvent ese) { + String uri = ese.getEndpoint().getEndpointUri(); + if (uri.startsWith("sql:") || uri.startsWith("jdbc:")) { + Exchange exchange = ese.getExchange(); + + String query = extractQuery(uri); + // for jdbc component, try to get query from exchange header + if (query == null) { + Object headerQuery = exchange.getMessage().getHeader("CamelSqlQuery"); + if (headerQuery != null) { + query = headerQuery.toString(); + } + } + + JsonObject jo = new JsonObject(); + jo.put("timestamp", event.getTimestamp()); + jo.put("exchangeId", exchange.getExchangeId()); + jo.put("routeId", exchange.getFromRouteId()); + jo.put("endpoint", uri); + if (query != null) { + jo.put("query", query); + jo.put("category", detectCategory(query)); + } else { + jo.put("query", uri); + jo.put("category", "OTHER"); + } + jo.put("duration", ese.getTimeTaken()); + jo.put("failed", exchange.isFailed()); + + // row/update counts from sql and jdbc component headers + Object rc = exchange.getMessage().getHeader("CamelSqlRowCount"); + if (rc == null) { + rc = exchange.getMessage().getHeader("CamelJdbcRowCount"); + } + if (rc instanceof Number) { + jo.put("rowCount", ((Number) rc).intValue()); + } + Object uc = exchange.getMessage().getHeader("CamelSqlUpdateCount"); + if (uc == null) { + uc = exchange.getMessage().getHeader("CamelJdbcUpdateCount"); + } + if (uc instanceof Number) { + jo.put("updateCount", ((Number) uc).intValue()); + } + + int p = pos.getAndUpdate(operand -> ++operand % capacity); + events[p] = jo; + } + } + } + } +} diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-sql-trace.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-sql-trace.adoc new file mode 100644 index 000000000000..65255889b756 --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-sql-trace.adoc @@ -0,0 +1,28 @@ + +// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE += camel get sql-trace + +Get SQL query trace data + + +== Usage + +[source,bash] +---- +camel get sql-trace [options] +---- + + + +== Options + +[cols="2,5,1,2",options="header"] +|=== +| Option | Description | Default | Type +| `--json` | Output in JSON Format | | boolean +| `--sort` | Sort by pid, name or age | pid | String +| `--watch` | Execute periodically and showing output fullscreen | | boolean +| `-h,--help` | Display the help and sub-commands | | boolean +|=== + + diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc index 8d2eb511b28e..5365803b1101 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc @@ -45,6 +45,7 @@ camel get [options] | xref:jbang-commands/camel-jbang-get-route-controller.adoc[route-controller] | List status of route controller | xref:jbang-commands/camel-jbang-get-service.adoc[service] | Get services of Camel integrations | xref:jbang-commands/camel-jbang-get-source.adoc[source] | Display Camel route source code +| xref:jbang-commands/camel-jbang-get-sql-trace.adoc[sql-trace] | Get SQL query trace data | xref:jbang-commands/camel-jbang-get-startup-recorder.adoc[startup-recorder] | Display startup recording | xref:jbang-commands/camel-jbang-get-transformer.adoc[transformer] | Get list of data type transformers | xref:jbang-commands/camel-jbang-get-variable.adoc[variable] | List variables of Camel integrations diff --git a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java index fe943b169bb7..d8d562fb77a9 100644 --- a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java +++ b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java @@ -1504,6 +1504,13 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C root.put("dataSources", json); } } + DevConsole dc28 = dcr.resolveById("sql-trace"); + if (dc28 != null) { + JsonObject json = (JsonObject) dc28.call(DevConsole.MediaType.JSON); + if (json != null && !json.isEmpty()) { + root.put("sqlTrace", json); + } + } } // various details JsonObject mem = collectMemory(); diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json index 6ee1954980ad..93ef87cda9f2 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json @@ -15,7 +15,7 @@ { "name": "eval", "fullName": "eval", "description": "Evaluate Camel expressions and scripts", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", "fullName": "eval expression", "description": "Evaluates Camel expression", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...] { "name": "explain", "fullName": "explain", "description": "Explain what a Camel route does using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama', 'openai' (OpenAI-compatible), or 'anthropic' (A [...] { "name": "export", "fullName": "export", "description": "Export to other runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": "--build-property", "description": "Maven build properties, ex. --build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { "names": "--camel-spring-boot-version", "description": "Camel version to use with Spring Boot", "javaType": "java.lang.String", "ty [...] - { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "bean", "fullName": "get [...] + { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "bean", "fullName": "get [...] { "name": "harden", "fullName": "harden", "description": "Suggest security hardening for Camel routes using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...] { "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio web console", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ { "names": "--host", "description": "Hostname to bind the Hawtio web console to", "defaultValue": "127.0.0.1", "javaType": "java.lang.String", "type": "string" }, { "names": "--openUrl", "description": "To automatic open Hawtio web console in the web browser", "defaultValue": "true", "javaType": "boolean", "type": [...] { "name": "infra", "fullName": "infra", "description": "List and Run external services for testing and prototyping", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ { "names": "--json", "description": "Output in JSON Format", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "infra [...] diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index da28f9ad87bb..86f19a1abd67 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -180,6 +180,7 @@ public class CamelJBangMain implements Callable<Integer> { .addSubcommand("route-controller", new CommandLine(new RouteControllerAction(this))) .addSubcommand("service", new CommandLine(new ListService(this))) .addSubcommand("source", new CommandLine(new CamelSourceAction(this))) + .addSubcommand("sql-trace", new CommandLine(new ListSqlTrace(this))) .addSubcommand("startup-recorder", new CommandLine(new CamelStartupRecorderAction(this))) .addSubcommand("transformer", new CommandLine(new ListTransformer(this))) .addSubcommand("variable", new CommandLine(new ListVariable(this))) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListSqlTrace.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListSqlTrace.java new file mode 100644 index 000000000000..275fe015b6b2 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListSqlTrace.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.process; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import com.github.freva.asciitable.AsciiTable; +import com.github.freva.asciitable.Column; +import com.github.freva.asciitable.HorizontalAlign; +import com.github.freva.asciitable.OverflowBehaviour; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.PidNameAgeCompletionCandidates; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.util.TimeUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "sql-trace", description = "Get SQL query trace data", sortOptions = false, + showDefaultValues = true, + footer = { + "%nExamples:", + " camel get sql-trace", + " camel get sql-trace --watch" }) +public class ListSqlTrace extends ProcessWatchCommand { + + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--sort" }, completionCandidates = PidNameAgeCompletionCandidates.class, + description = "Sort by pid, name or age", defaultValue = "pid") + String sort; + + public ListSqlTrace(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doProcessWatchCall() throws Exception { + List<Row> rows = new ArrayList<>(); + + List<Long> pids = findPids(name); + ProcessHandle.allProcesses() + .filter(ph -> pids.contains(ph.pid())) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + JsonObject context = (JsonObject) root.get("context"); + JsonObject sqlTrace = (JsonObject) root.get("sqlTrace"); + if (context != null && sqlTrace != null) { + JsonArray array = (JsonArray) sqlTrace.get("statements"); + if (array != null) { + for (int i = 0; i < array.size(); i++) { + JsonObject o = (JsonObject) array.get(i); + Row row = new Row(); + row.name = context.getString("name"); + if ("CamelJBang".equals(row.name)) { + row.name = ProcessHelper.extractName(root, ph); + } + row.pid = Long.toString(ph.pid()); + row.uptime = extractSince(ph); + row.age = TimeUtils.printSince(row.uptime); + row.timestamp = o.getLongOrDefault("timestamp", 0); + row.exchangeId = o.getString("exchangeId"); + row.routeId = o.getString("routeId"); + row.query = o.getString("query"); + row.category = o.getString("category"); + row.endpoint = o.getString("endpoint"); + row.duration = o.getLongOrDefault("duration", 0); + row.rowCount = o.getIntegerOrDefault("rowCount", 0); + row.updateCount = o.getIntegerOrDefault("updateCount", 0); + row.failed = o.getBooleanOrDefault("failed", false); + rows.add(row); + } + } + } + } + }); + + // sort rows + rows.sort(this::sortRow); + + if (!rows.isEmpty()) { + printTable(rows); + } + + return 0; + } + + protected void printTable(List<Row> rows) { + if (jsonOutput) { + printer().println(Jsoner.serialize(rows.stream().map(r -> { + JsonObject jo = new JsonObject(); + jo.put("pid", r.pid); + jo.put("name", r.name); + jo.put("age", r.age); + jo.put("timestamp", r.timestamp); + jo.put("exchangeId", r.exchangeId); + jo.put("routeId", r.routeId); + jo.put("query", r.query); + jo.put("category", r.category); + jo.put("endpoint", r.endpoint); + jo.put("duration", r.duration); + jo.put("rowCount", r.rowCount); + jo.put("updateCount", r.updateCount); + jo.put("failed", r.failed); + return jo; + }).collect(Collectors.toList()))); + return; + } + int tw = terminalWidth(); + int sqlW = Math.max(20, Math.min(80, tw - 100)); + printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, Arrays.asList( + new Column().header("PID").headerAlign(HorizontalAlign.CENTER).with(r -> r.pid), + new Column().header("NAME").dataAlign(HorizontalAlign.LEFT).maxWidth(30, OverflowBehaviour.ELLIPSIS_RIGHT) + .with(r -> r.name), + new Column().header("AGE").headerAlign(HorizontalAlign.CENTER).with(r -> r.age), + new Column().header("TIME").headerAlign(HorizontalAlign.CENTER).with(this::formatTime), + new Column().header("CAT").dataAlign(HorizontalAlign.LEFT).with(r -> r.category != null ? r.category : ""), + new Column().header("SQL").dataAlign(HorizontalAlign.LEFT) + .maxWidth(sqlW, OverflowBehaviour.ELLIPSIS_RIGHT) + .with(r -> r.query != null ? r.query : ""), + new Column().header("ROUTE").dataAlign(HorizontalAlign.LEFT) + .maxWidth(20, OverflowBehaviour.ELLIPSIS_RIGHT) + .with(r -> r.routeId != null ? r.routeId : ""), + new Column().header("DURATION").headerAlign(HorizontalAlign.RIGHT).dataAlign(HorizontalAlign.RIGHT) + .with(r -> r.duration + " ms"), + new Column().header("ROWS").headerAlign(HorizontalAlign.RIGHT).dataAlign(HorizontalAlign.RIGHT) + .with(this::formatRows), + new Column().header("STATUS").dataAlign(HorizontalAlign.CENTER) + .with(r -> r.failed ? "FAIL" : "OK")))); + } + + private String formatTime(Row r) { + if (r.timestamp > 0) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(r.timestamp), ZoneId.systemDefault()) + .format(TIME_FMT); + } + return ""; + } + + private String formatRows(Row r) { + if (r.rowCount > 0) { + return Integer.toString(r.rowCount); + } else if (r.updateCount > 0) { + return Integer.toString(r.updateCount); + } + return ""; + } + + protected int sortRow(Row o1, Row o2) { + String s = sort; + int negate = 1; + if (s.startsWith("-")) { + s = s.substring(1); + negate = -1; + } + switch (s) { + case "pid": + return Long.compare(Long.parseLong(o1.pid), Long.parseLong(o2.pid)) * negate; + case "name": + return o1.name.compareToIgnoreCase(o2.name) * negate; + case "age": + return Long.compare(o1.uptime, o2.uptime) * negate; + default: + return 0; + } + } + + static class Row { + String pid; + String name; + long uptime; + String age; + long timestamp; + String exchangeId; + String routeId; + String query; + String category; + String endpoint; + long duration; + int rowCount; + int updateCount; + boolean failed; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java index a4009ab251c4..2820a1956d93 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java @@ -83,6 +83,12 @@ class IntegrationInfo { final List<HttpEndpointInfo> httpEndpoints = new ArrayList<>(); final List<ConfigurationTab.ConfigProperty> configProperties = new ArrayList<>(); final List<DataSourceInfo> dataSources = new ArrayList<>(); + final List<SqlTraceInfo> sqlTraceStatements = new ArrayList<>(); + long sqlTraceTotal; + long sqlTraceAvgTime; + long sqlTraceSlowestTime; + long sqlTraceSlowCount; + long sqlTraceFailedCount; String httpServer; String readmeFiles; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 99efb93f2856..d8fa65a41ec7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -89,8 +89,8 @@ class McpFacade { static final String[] MORE_TAB_NAMES = { "Beans", "Browse", "Circuit Breaker", "Classpath", "Configuration", - "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL Query", "Spans", "Process", "Startup", - "Threads" + "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL Query", "SQL Trace", "Spans", "Process", + "Startup", "Threads" }; private final MonitorContext ctx; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java index 6a615d808037..ba1c87e859ed 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java @@ -176,7 +176,7 @@ class PopupManager { return true; } if (ke.isDown()) { - morePopupState.selectNext(15); + morePopupState.selectNext(16); return true; } int shortcutSel = morePopupShortcut(ke); @@ -239,7 +239,7 @@ class PopupManager { void renderMorePopup(Frame frame, Rect area) { int popupW = 22; - int popupH = 17; + int popupH = 18; // Position just below the "0 More▾" tab label int dividerW = CharWidth.of(" | "); int tabBarX = 0; @@ -272,6 +272,7 @@ class PopupManager { ListItem.from(Line.from(Span.raw(" "), Span.styled("M", keyStyle), Span.raw("emory"))), ListItem.from(Line.from(Span.raw(" M"), Span.styled("e", keyStyle), Span.raw("trics"))), ListItem.from(Line.from(Span.raw(" S"), Span.styled("Q", keyStyle), Span.raw("L Query"))), + ListItem.from(Line.from(Span.raw(" SQL T"), Span.styled("r", keyStyle), Span.raw("ace"))), ListItem.from(Line.from(Span.raw(" "), Span.styled("O", keyStyle), Span.raw("Tel Spans"))), ListItem.from(Line.from(Span.raw(" "), Span.styled("P", keyStyle), Span.raw("rocess"))), ListItem.from(Line.from(Span.raw(" "), Span.styled("S", keyStyle), Span.raw("tartup"))), @@ -408,18 +409,21 @@ class PopupManager { if (ke.isChar('q')) { return 10; } - if (ke.isChar('o')) { + if (ke.isChar('r')) { return 11; } - if (ke.isChar('p')) { + if (ke.isChar('o')) { return 12; } - if (ke.isChar('s')) { + if (ke.isChar('p')) { return 13; } - if (ke.isChar('t')) { + if (ke.isChar('s')) { return 14; } + if (ke.isChar('t')) { + return 15; + } return -1; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceInfo.java new file mode 100644 index 000000000000..cdd6168607c9 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceInfo.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +class SqlTraceInfo { + String exchangeId; + String routeId; + String query; + String category; + String endpoint; + long timestamp; + long duration; + int rowCount; + int updateCount; + boolean failed; +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceTab.java new file mode 100644 index 000000000000..37fe1c062a63 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceTab.java @@ -0,0 +1,336 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.table.Cell; +import dev.tamboui.widgets.table.Row; +import dev.tamboui.widgets.table.Table; +import dev.tamboui.widgets.table.TableState; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; + +class SqlTraceTab implements MonitorTab { + + private static final String[] SORT_COLUMNS = { "time", "category", "sql", "route", "duration", "rows" }; + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + + private final MonitorContext ctx; + private final TableState tableState = new TableState(); + private String sort = "time"; + private int sortIndex; + private boolean sortReversed; + + SqlTraceTab(MonitorContext ctx) { + this.ctx = ctx; + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isChar('s')) { + sortIndex = (sortIndex + 1) % SORT_COLUMNS.length; + sort = SORT_COLUMNS[sortIndex]; + sortReversed = false; + return true; + } + if (ke.isChar('S')) { + sortReversed = !sortReversed; + return true; + } + return false; + } + + @Override + public boolean handleEscape() { + return false; + } + + @Override + public void navigateUp() { + } + + @Override + public void navigateDown() { + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + List<Rect> layout = Layout.vertical() + .constraints(Constraint.length(3), Constraint.fill()) + .split(area); + + renderKpiStrip(frame, layout.get(0), info); + renderTable(frame, layout.get(1), info); + } + + private void renderKpiStrip(Frame frame, Rect area, IntegrationInfo info) { + Style labelStyle = Style.EMPTY.dim(); + Style valueStyle = Style.EMPTY.fg(Color.CYAN).bold(); + Style warnStyle = Style.EMPTY.fg(Color.YELLOW).bold(); + Style errorStyle = Style.EMPTY.fg(Color.LIGHT_RED).bold(); + + List<Span> spans = new ArrayList<>(); + spans.add(Span.styled(" Total: ", labelStyle)); + spans.add(Span.styled(String.valueOf(info.sqlTraceTotal), valueStyle)); + spans.add(Span.styled(" Avg: ", labelStyle)); + spans.add(Span.styled(info.sqlTraceAvgTime + " ms", valueStyle)); + spans.add(Span.styled(" Slowest: ", labelStyle)); + Style slowestStyle = info.sqlTraceSlowestTime >= 100 ? warnStyle : valueStyle; + spans.add(Span.styled(info.sqlTraceSlowestTime + " ms", slowestStyle)); + spans.add(Span.styled(" Slow(>=100ms): ", labelStyle)); + Style slowStyle = info.sqlTraceSlowCount > 0 ? warnStyle : valueStyle; + spans.add(Span.styled(String.valueOf(info.sqlTraceSlowCount), slowStyle)); + spans.add(Span.styled(" Failed: ", labelStyle)); + Style failStyle = info.sqlTraceFailedCount > 0 ? errorStyle : valueStyle; + spans.add(Span.styled(String.valueOf(info.sqlTraceFailedCount), failStyle)); + + Paragraph kpi = Paragraph.builder() + .text(Text.from(Line.from(spans))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" SQL Trace ").build()) + .build(); + frame.renderWidget(kpi, area); + } + + private void renderTable(Frame frame, Rect area, IntegrationInfo info) { + List<SqlTraceInfo> sorted = new ArrayList<>(info.sqlTraceStatements); + sorted.sort(this::sortTrace); + + List<Row> rows = new ArrayList<>(); + for (SqlTraceInfo si : sorted) { + Style durStyle = si.duration >= 100 ? Style.EMPTY.fg(Color.YELLOW) : Style.EMPTY; + Style statusStyle = si.failed ? Style.EMPTY.fg(Color.LIGHT_RED) : Style.EMPTY.fg(Color.GREEN); + String status = si.failed ? "FAIL" : "OK"; + + String time = ""; + if (si.timestamp > 0) { + time = LocalDateTime.ofInstant(Instant.ofEpochMilli(si.timestamp), ZoneId.systemDefault()) + .format(TIME_FMT); + } + + String rowsStr = ""; + if (si.rowCount > 0) { + rowsStr = String.valueOf(si.rowCount); + } else if (si.updateCount > 0) { + rowsStr = String.valueOf(si.updateCount); + } + + rows.add(Row.from( + Cell.from(Span.styled(time, Style.EMPTY.dim())), + Cell.from(Span.styled(si.category != null ? si.category : "", categoryStyle(si.category))), + Cell.from(si.query != null ? si.query : ""), + Cell.from(Span.styled(si.routeId != null ? si.routeId : "", Style.EMPTY.fg(Color.CYAN))), + rightCell(String.valueOf(si.duration), 10, durStyle), + rightCell(rowsStr, 8), + Cell.from(Span.styled(status, statusStyle)))); + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(""), Cell.from(""), + Cell.from(Span.styled("No SQL statements traced", Style.EMPTY.dim())), + Cell.from(""), Cell.from(""), Cell.from(""), Cell.from(""))); + } + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled(sortLabel("TIME", "time"), sortStyle("time"))), + Cell.from(Span.styled(sortLabel("CAT", "category"), sortStyle("category"))), + Cell.from(Span.styled(sortLabel("SQL", "sql"), sortStyle("sql"))), + Cell.from(Span.styled(sortLabel("ROUTE", "route"), sortStyle("route"))), + rightCell(sortLabel("DURATION", "duration"), 10, sortStyle("duration")), + rightCell(sortLabel("ROWS", "rows"), 8, sortStyle("rows")), + Cell.from(Span.styled("STATUS", Style.EMPTY.bold())))) + .widths( + Constraint.length(14), + Constraint.length(8), + Constraint.fill(), + Constraint.length(20), + Constraint.length(10), + Constraint.length(8), + Constraint.length(8)) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Statements sort:" + sort + " ").build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + } + + private static Style categoryStyle(String category) { + if (category == null) { + return Style.EMPTY; + } + return switch (category) { + case "SELECT" -> Style.EMPTY.fg(Color.CYAN); + case "INSERT" -> Style.EMPTY.fg(Color.GREEN); + case "UPDATE" -> Style.EMPTY.fg(Color.YELLOW); + case "DELETE" -> Style.EMPTY.fg(Color.LIGHT_RED); + default -> Style.EMPTY; + }; + } + + @Override + public void renderFooter(List<Span> spans) { + hint(spans, "Esc", "back"); + hint(spans, "s", "sort"); + } + + private String sortLabel(String label, String column) { + return MonitorContext.sortLabel(label, column, sort, sortReversed); + } + + private Style sortStyle(String column) { + return MonitorContext.sortStyle(column, sort); + } + + private int sortTrace(SqlTraceInfo a, SqlTraceInfo b) { + int result = switch (sort) { + case "category" -> { + String ca = a.category != null ? a.category : ""; + String cb = b.category != null ? b.category : ""; + yield ca.compareToIgnoreCase(cb); + } + case "sql" -> { + String qa = a.query != null ? a.query : ""; + String qb = b.query != null ? b.query : ""; + yield qa.compareToIgnoreCase(qb); + } + case "route" -> { + String ra = a.routeId != null ? a.routeId : ""; + String rb = b.routeId != null ? b.routeId : ""; + yield ra.compareToIgnoreCase(rb); + } + case "duration" -> Long.compare(b.duration, a.duration); + case "rows" -> { + int ra = a.rowCount > 0 ? a.rowCount : a.updateCount; + int rb = b.rowCount > 0 ? b.rowCount : b.updateCount; + yield Integer.compare(rb, ra); + } + default -> Long.compare(b.timestamp, a.timestamp); // "time" — newest first + }; + return sortReversed ? -result : result; + } + + @Override + public SelectionContext getSelectionContext() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.sqlTraceStatements.isEmpty()) { + return null; + } + List<SqlTraceInfo> sorted = new ArrayList<>(info.sqlTraceStatements); + sorted.sort(this::sortTrace); + List<String> items = sorted.stream() + .map(s -> s.query != null ? s.query : s.endpoint) + .toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "SQL Trace"); + } + + @Override + public String getHelpText() { + return """ + # SQL Trace + + Traces SQL query executions flowing through `camel-sql` and `camel-jdbc` + components. Captures individual executions with timing, row counts, + and failure status. + + ## KPI Strip + + The top bar shows aggregate statistics: + - **Total** — Total number of SQL statements traced + - **Avg** — Average execution time in milliseconds + - **Slowest** — Longest single execution (yellow when >= 100ms) + - **Slow(>=100ms)** — Count of slow queries (yellow when > 0) + - **Failed** — Count of failed executions (red when > 0) + + ## Table Columns + + - **TIME** — Timestamp of the execution + - **CAT** — SQL category: SELECT, INSERT, UPDATE, DELETE, CALL, or OTHER + - **SQL** — The SQL query text + - **ROUTE** — The Camel route ID that executed the query + - **DURATION** — Execution time in ms (yellow when >= 100ms) + - **ROWS** — Row count (for SELECT) or update count (for INSERT/UPDATE/DELETE) + - **STATUS** — OK (green) or FAIL (red) + + ## Keys + + - `Up/Down` — select statement + - `s` — cycle sort column + - `S` — reverse sort order + """; + } + + @Override + public JsonObject getTableDataAsJson() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + return null; + } + JsonObject result = new JsonObject(); + result.put("tab", "SQL Trace"); + JsonArray rows = new JsonArray(); + for (SqlTraceInfo si : info.sqlTraceStatements) { + JsonObject row = new JsonObject(); + row.put("timestamp", si.timestamp); + row.put("exchangeId", si.exchangeId); + row.put("routeId", si.routeId); + row.put("query", si.query); + row.put("category", si.category); + row.put("endpoint", si.endpoint); + row.put("duration", si.duration); + row.put("rowCount", si.rowCount); + row.put("updateCount", si.updateCount); + row.put("failed", si.failed); + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", info.sqlTraceStatements.size()); + Integer sel = tableState.selected(); + result.put("selectedIndex", sel != null ? sel : -1); + return result; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java index 515c080554fb..54ef3c317adf 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java @@ -522,6 +522,37 @@ final class StatusParser { } } + // Parse sqlTrace + JsonObject sqlTraceObj = (JsonObject) root.get("sqlTrace"); + if (sqlTraceObj != null) { + JsonObject summary = (JsonObject) sqlTraceObj.get("summary"); + if (summary != null) { + info.sqlTraceTotal = summary.getLongOrDefault("totalQueries", 0); + info.sqlTraceAvgTime = summary.getLongOrDefault("avgTime", 0); + info.sqlTraceSlowestTime = summary.getLongOrDefault("slowestTime", 0); + info.sqlTraceSlowCount = summary.getLongOrDefault("slowCount", 0); + info.sqlTraceFailedCount = summary.getLongOrDefault("failedCount", 0); + } + JsonArray stmts = (JsonArray) sqlTraceObj.get("statements"); + if (stmts != null) { + for (Object s : stmts) { + JsonObject sj = (JsonObject) s; + SqlTraceInfo si = new SqlTraceInfo(); + si.exchangeId = sj.getString("exchangeId"); + si.routeId = sj.getString("routeId"); + si.query = sj.getString("query"); + si.category = sj.getString("category"); + si.endpoint = sj.getString("endpoint"); + si.timestamp = sj.getLongOrDefault("timestamp", 0); + si.duration = sj.getLongOrDefault("duration", 0); + si.rowCount = sj.getIntegerOrDefault("rowCount", 0); + si.updateCount = sj.getIntegerOrDefault("updateCount", 0); + si.failed = sj.getBooleanOrDefault("failed", false); + info.sqlTraceStatements.add(si); + } + } + } + return info; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java index 68a948ed51e2..c0a9051f2125 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -87,6 +87,7 @@ class TabRegistry { private OverviewTab overviewTab; private DataSourceTab dataSourceTab; private SqlQueryTab sqlQueryTab; + private SqlTraceTab sqlTraceTab; private MonitorTab activeMoreTab; @@ -105,6 +106,7 @@ class TabRegistry { consumersTab = new ConsumersTab(ctx); dataSourceTab = new DataSourceTab(ctx); sqlQueryTab = new SqlQueryTab(ctx); + sqlTraceTab = new SqlTraceTab(ctx); endpointsTab = new EndpointsTab(ctx, dataService.metrics()); httpTab = new HttpTab(ctx); healthTab = new HealthTab(ctx); @@ -213,10 +215,11 @@ class TabRegistry { case 8 -> memoryTab; case 9 -> metricsTab; case 10 -> sqlQueryTab; - case 11 -> spansTab; - case 12 -> processTab; - case 13 -> startupTab; - case 14 -> threadsTab; + case 11 -> sqlTraceTab; + case 12 -> spansTab; + case 13 -> processTab; + case 14 -> startupTab; + case 15 -> threadsTab; default -> null; }; if (activeMoreTab != null) { @@ -240,6 +243,7 @@ class TabRegistry { consumersTab.onIntegrationChanged(); dataSourceTab.onIntegrationChanged(); sqlQueryTab.onIntegrationChanged(); + sqlTraceTab.onIntegrationChanged(); circuitBreakerTab.onIntegrationChanged(); inflightTab.onIntegrationChanged(); spansTab.onIntegrationChanged(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceTabRenderTest.java new file mode 100644 index 000000000000..0b66e45a1490 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlTraceTabRenderTest.java @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SqlTraceTabRenderTest { + + private MonitorContext ctx; + private IntegrationInfo info; + + @BeforeEach + void setUp() { + info = new IntegrationInfo(); + info.pid = "1234"; + info.name = "test-app"; + + AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(List.of(info)); + AtomicReference<List<InfraInfo>> infraData = new AtomicReference<>(List.of()); + ctx = new MonitorContext(data, infraData); + ctx.selectedPid = "1234"; + } + + @Test + void renderShowsKpiStrip() { + info.sqlTraceTotal = 42; + info.sqlTraceAvgTime = 15; + info.sqlTraceSlowestTime = 120; + info.sqlTraceSlowCount = 3; + info.sqlTraceFailedCount = 1; + + SqlTraceTab tab = new SqlTraceTab(ctx); + String rendered = TuiTestHelper.renderToString(tab, 140, 25); + + assertTrue(rendered.contains("SQL Trace"), "Should show SQL Trace title"); + assertTrue(rendered.contains("Total:"), "Should show Total KPI"); + assertTrue(rendered.contains("42"), "Should show total count"); + assertTrue(rendered.contains("Avg:"), "Should show Avg KPI"); + } + + @Test + void renderShowsTableHeaders() { + addSqlTrace("SELECT * FROM users", "SELECT", "route1", 25, 10, false); + + SqlTraceTab tab = new SqlTraceTab(ctx); + String rendered = TuiTestHelper.renderToString(tab, 140, 25); + + assertTrue(rendered.contains("TIME"), "Should show TIME header"); + assertTrue(rendered.contains("CAT"), "Should show CAT header"); + assertTrue(rendered.contains("SQL"), "Should show SQL header"); + assertTrue(rendered.contains("ROUTE"), "Should show ROUTE header"); + assertTrue(rendered.contains("DURATION"), "Should show DURATION header"); + } + + @Test + void renderShowsSqlData() { + addSqlTrace("SELECT * FROM orders", "SELECT", "orderRoute", 15, 5, false); + + SqlTraceTab tab = new SqlTraceTab(ctx); + String rendered = TuiTestHelper.renderToString(tab, 140, 25); + + assertTrue(rendered.contains("SELECT * FROM orders"), "Should render SQL query text"); + assertTrue(rendered.contains("orderRoute"), "Should render route ID"); + } + + @Test + void renderSlowQueryHighlighted() { + addSqlTrace("SELECT * FROM big_table", "SELECT", "route1", 150, 1000, false); + + SqlTraceTab tab = new SqlTraceTab(ctx); + + Rect area = new Rect(0, 0, 140, 25); + Buffer buffer = Buffer.empty(area); + Frame frame = Frame.forTesting(buffer); + tab.render(frame, area); + + boolean foundYellow = TuiTestHelper.findCellWithColor(buffer, "1", Color.YELLOW); + assertTrue(foundYellow, "Slow query duration should be highlighted in YELLOW"); + } + + @Test + void renderFailedQueryInRed() { + addSqlTrace("INSERT INTO bad_table", "INSERT", "route1", 5, 0, true); + + SqlTraceTab tab = new SqlTraceTab(ctx); + + Rect area = new Rect(0, 0, 140, 25); + Buffer buffer = Buffer.empty(area); + Frame frame = Frame.forTesting(buffer); + tab.render(frame, area); + + boolean foundRed = TuiTestHelper.findCellWithColor(buffer, "F", Color.LIGHT_RED); + assertTrue(foundRed, "Failed status should be rendered in LIGHT_RED"); + } + + @Test + void renderEmptyShowsPlaceholder() { + SqlTraceTab tab = new SqlTraceTab(ctx); + String rendered = TuiTestHelper.renderToString(tab, 140, 25); + + assertTrue(rendered.contains("No SQL statements"), "Should show placeholder when no traces exist"); + } + + @Test + void renderNoSelectionShowsPrompt() { + ctx.selectedPid = null; + + SqlTraceTab tab = new SqlTraceTab(ctx); + String rendered = TuiTestHelper.renderToString(tab, 100, 25); + + assertTrue(rendered.contains("No integration selected") || rendered.contains("Select an integration"), + "Should show selection prompt when no integration selected"); + } + + @Test + void sortCycleChangesSortIndicator() { + addSqlTrace("SELECT 1", "SELECT", "route1", 10, 1, false); + + SqlTraceTab tab = new SqlTraceTab(ctx); + + tab.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.NONE)); + String rendered = TuiTestHelper.renderToString(tab, 140, 25); + + assertTrue(rendered.contains("sort:category"), "Sort should cycle to 'category' after pressing 's'"); + } + + @Test + void renderFooterHints() { + SqlTraceTab tab = new SqlTraceTab(ctx); + List<Span> footerSpans = new ArrayList<>(); + tab.renderFooter(footerSpans); + + String footer = footerSpans.stream() + .map(Span::content) + .reduce("", String::concat); + + assertTrue(footer.contains("Esc"), "Footer should contain Esc hint"); + assertTrue(footer.contains("sort"), "Footer should contain sort hint"); + } + + @Test + void helpTextIsAvailable() { + SqlTraceTab tab = new SqlTraceTab(ctx); + String help = tab.getHelpText(); + assertNotNull(help, "Help text should not be null"); + assertTrue(help.contains("SQL Trace"), "Help text should mention SQL Trace"); + assertTrue(help.contains("KPI Strip"), "Help text should describe KPI strip"); + } + + // ---- Helper methods ---- + + private void addSqlTrace( + String query, String category, String routeId, long duration, int rowCount, + boolean failed) { + SqlTraceInfo si = new SqlTraceInfo(); + si.query = query; + si.category = category; + si.routeId = routeId; + si.duration = duration; + si.rowCount = rowCount; + si.failed = failed; + si.exchangeId = "ID-test-1234"; + si.endpoint = "sql:" + query; + si.timestamp = System.currentTimeMillis(); + info.sqlTraceStatements.add(si); + } +}
