This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch sql-query-tui in repository https://gitbox.apache.org/repos/asf/camel.git
commit 572219b2e651f544946a2146a290820abe2ed9fc Author: Claus Ibsen <[email protected]> AuthorDate: Sun Jun 28 10:56:41 2026 +0200 chore: camel-jbang - Add SQL query dev-console, CLI command, TUI tab, and MCP tool Adds end-to-end SQL query support: a new sql-query dev-console that executes queries against DataSource beans, a `camel cmd sql` CLI command, a TUI SQL Query tab with query history (reusable InputHistory class), and a tui_execute_sql MCP tool for AI agents. Co-Authored-By: Claude <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../impl/console/SqlQueryDevConsoleConfigurer.java | 77 +++ .../org/apache/camel/dev-console/sql-query.json | 15 + ...rg.apache.camel.impl.console.SqlQueryDevConsole | 2 + .../org/apache/camel/dev-console/sql-query | 2 + .../org/apache/camel/dev-consoles.properties | 2 +- .../camel/impl/console/SqlQueryDevConsole.java | 312 ++++++++++ .../pages/jbang-commands/camel-jbang-cmd-sql.adoc | 29 + .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc | 1 + .../camel/cli/connector/LocalCliConnector.java | 25 + .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../dsl/jbang/core/commands/CamelJBangMain.java | 1 + .../core/commands/action/CamelSqlQueryAction.java | 173 ++++++ .../camel/dsl/jbang/core/common/RuntimeHelper.java | 25 +- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 61 +- .../dsl/jbang/core/commands/tui/InputHistory.java | 153 +++++ .../dsl/jbang/core/commands/tui/SqlQueryTab.java | 654 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/TuiMcpServer.java | 30 + 17 files changed, 1545 insertions(+), 19 deletions(-) diff --git a/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlQueryDevConsoleConfigurer.java b/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlQueryDevConsoleConfigurer.java new file mode 100644 index 000000000000..61bc950601a3 --- /dev/null +++ b/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlQueryDevConsoleConfigurer.java @@ -0,0 +1,77 @@ +/* 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.SqlQueryDevConsole; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo") +@SuppressWarnings("unchecked") +public class SqlQueryDevConsoleConfigurer 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("DefaultMaxRows", int.class); + map.put("DefaultQueryTimeout", int.class); + ALL_OPTIONS = map; + } + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + org.apache.camel.impl.console.SqlQueryDevConsole target = (org.apache.camel.impl.console.SqlQueryDevConsole) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": target.setCamelContext(property(camelContext, org.apache.camel.CamelContext.class, value)); return true; + case "defaultmaxrows": + case "defaultMaxRows": target.setDefaultMaxRows(property(camelContext, int.class, value)); return true; + case "defaultquerytimeout": + case "defaultQueryTimeout": target.setDefaultQueryTimeout(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 "defaultmaxrows": + case "defaultMaxRows": return int.class; + case "defaultquerytimeout": + case "defaultQueryTimeout": return int.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + org.apache.camel.impl.console.SqlQueryDevConsole target = (org.apache.camel.impl.console.SqlQueryDevConsole) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": return target.getCamelContext(); + case "defaultmaxrows": + case "defaultMaxRows": return target.getDefaultMaxRows(); + case "defaultquerytimeout": + case "defaultQueryTimeout": return target.getDefaultQueryTimeout(); + default: return null; + } + } +} + diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-query.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-query.json new file mode 100644 index 000000000000..1b98f12a4690 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-query.json @@ -0,0 +1,15 @@ +{ + "console": { + "kind": "console", + "group": "camel", + "name": "sql-query", + "title": "SQL Query", + "description": "Execute SQL queries on DataSource beans", + "deprecated": false, + "javaType": "org.apache.camel.impl.console.SqlQueryDevConsole", + "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.SqlQueryDevConsole b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlQueryDevConsole new file mode 100644 index 000000000000..cf682ab4c486 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlQueryDevConsole @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.SqlQueryDevConsoleConfigurer diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-query b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-query new file mode 100644 index 000000000000..9ddcee3835ca --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-query @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.SqlQueryDevConsole 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 e25def6b5f32..40b26b866b52 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 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 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/SqlQueryDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java new file mode 100644 index 000000000000..81ecc1b3c460 --- /dev/null +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java @@ -0,0 +1,312 @@ +/* + * 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.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.Map; + +import javax.sql.DataSource; + +import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.annotations.DevConsole; +import org.apache.camel.support.console.AbstractDevConsole; +import org.apache.camel.util.StopWatch; +import org.apache.camel.util.TimeUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +@DevConsole(name = "sql-query", displayName = "SQL Query", description = "Execute SQL queries on DataSource beans") +@Configurer(extended = true) +public class SqlQueryDevConsole extends AbstractDevConsole { + + /** + * The SQL query to execute + */ + public static final String SQL = "sql"; + + /** + * Name of the DataSource bean in the registry (auto-detected if only one exists) + */ + public static final String DATASOURCE = "datasource"; + + /** + * Maximum number of rows to return + */ + public static final String MAX_ROWS = "maxRows"; + + /** + * Query timeout in seconds + */ + public static final String QUERY_TIMEOUT = "queryTimeout"; + + @Metadata(defaultValue = "100", + description = "Maximum number of rows to return from a query") + private int defaultMaxRows = 100; + + @Metadata(defaultValue = "30", + description = "Default query timeout in seconds") + private int defaultQueryTimeout = 30; + + public SqlQueryDevConsole() { + super("camel", "sql-query", "SQL Query", "Execute SQL queries on DataSource beans"); + } + + public int getDefaultMaxRows() { + return defaultMaxRows; + } + + public void setDefaultMaxRows(int defaultMaxRows) { + this.defaultMaxRows = defaultMaxRows; + } + + public int getDefaultQueryTimeout() { + return defaultQueryTimeout; + } + + public void setDefaultQueryTimeout(int defaultQueryTimeout) { + this.defaultQueryTimeout = defaultQueryTimeout; + } + + @Override + protected String doCallText(Map<String, Object> options) { + StringBuilder sb = new StringBuilder(); + + String sql = (String) options.get(SQL); + if (sql == null || sql.isBlank()) { + sb.append("No SQL query provided\n"); + return sb.toString(); + } + + String dsName = (String) options.get(DATASOURCE); + int maxRows = parseIntOption(options, MAX_ROWS, defaultMaxRows); + int queryTimeout = parseIntOption(options, QUERY_TIMEOUT, defaultQueryTimeout); + + DataSource ds; + String resolvedName; + if (dsName != null && !dsName.isBlank()) { + ds = getCamelContext().getRegistry().lookupByNameAndType(dsName, DataSource.class); + resolvedName = dsName; + if (ds == null) { + sb.append(String.format("DataSource '%s' not found in registry%n", dsName)); + return sb.toString(); + } + } else { + Map<String, DataSource> all = getCamelContext().getRegistry().findByTypeWithName(DataSource.class); + if (all.isEmpty()) { + sb.append("No DataSource found in registry\n"); + return sb.toString(); + } + if (all.size() > 1) { + sb.append(String.format("Multiple DataSources found: %s. Specify one with --datasource%n", + String.join(", ", all.keySet()))); + return sb.toString(); + } + Map.Entry<String, DataSource> single = all.entrySet().iterator().next(); + ds = single.getValue(); + resolvedName = single.getKey(); + } + + StopWatch watch = new StopWatch(); + try (Connection conn = ds.getConnection(); + Statement stmt = conn.createStatement()) { + + stmt.setMaxRows(maxRows); + stmt.setQueryTimeout(queryTimeout); + + boolean hasResultSet = stmt.execute(sql); + long elapsed = watch.taken(); + + if (hasResultSet) { + try (ResultSet rs = stmt.getResultSet()) { + ResultSetMetaData meta = rs.getMetaData(); + int colCount = meta.getColumnCount(); + + sb.append(String.format("DataSource: %s%n", resolvedName)); + sb.append(String.format("Elapsed: %s%n%n", TimeUtils.printDuration(elapsed))); + + // column headers + for (int i = 1; i <= colCount; i++) { + if (i > 1) { + sb.append(" | "); + } + sb.append(meta.getColumnLabel(i)); + } + sb.append("\n"); + + int rowCount = 0; + while (rs.next()) { + for (int i = 1; i <= colCount; i++) { + if (i > 1) { + sb.append(" | "); + } + Object val = rs.getObject(i); + sb.append(val != null ? String.valueOf(val) : "null"); + } + sb.append("\n"); + rowCount++; + } + sb.append(String.format("%n%d row(s)%n", rowCount)); + if (rowCount >= maxRows) { + sb.append("(truncated)\n"); + } + } + } else { + int updateCount = stmt.getUpdateCount(); + sb.append(String.format("DataSource: %s%n", resolvedName)); + sb.append(String.format("Update count: %d%n", updateCount)); + sb.append(String.format("Elapsed: %s%n", TimeUtils.printDuration(elapsed))); + } + } catch (Exception e) { + long elapsed = watch.taken(); + sb.append(String.format("Error: %s%n", e.getMessage())); + sb.append(String.format("Elapsed: %s%n", TimeUtils.printDuration(elapsed))); + } + + return sb.toString(); + } + + @Override + protected JsonObject doCallJson(Map<String, Object> options) { + JsonObject root = new JsonObject(); + + String sql = (String) options.get(SQL); + if (sql == null || sql.isBlank()) { + root.put("status", "error"); + root.put("message", "No SQL query provided"); + return root; + } + + String dsName = (String) options.get(DATASOURCE); + int maxRows = parseIntOption(options, MAX_ROWS, defaultMaxRows); + int queryTimeout = parseIntOption(options, QUERY_TIMEOUT, defaultQueryTimeout); + + DataSource ds; + String resolvedName; + if (dsName != null && !dsName.isBlank()) { + ds = getCamelContext().getRegistry().lookupByNameAndType(dsName, DataSource.class); + resolvedName = dsName; + if (ds == null) { + root.put("status", "error"); + root.put("message", String.format("DataSource '%s' not found in registry", dsName)); + return root; + } + } else { + Map<String, DataSource> all = getCamelContext().getRegistry().findByTypeWithName(DataSource.class); + if (all.isEmpty()) { + root.put("status", "error"); + root.put("message", "No DataSource found in registry"); + return root; + } + if (all.size() > 1) { + root.put("status", "error"); + root.put("message", "Multiple DataSources found, specify one: " + String.join(", ", all.keySet())); + // include available names for the caller + JsonArray names = new JsonArray(); + all.keySet().forEach(names::add); + root.put("availableDataSources", names); + return root; + } + Map.Entry<String, DataSource> single = all.entrySet().iterator().next(); + ds = single.getValue(); + resolvedName = single.getKey(); + } + + StopWatch watch = new StopWatch(); + try (Connection conn = ds.getConnection(); + Statement stmt = conn.createStatement()) { + + stmt.setMaxRows(maxRows); + stmt.setQueryTimeout(queryTimeout); + + boolean hasResultSet = stmt.execute(sql); + long elapsed = watch.taken(); + + root.put("status", "success"); + root.put("elapsed", elapsed); + root.put("datasource", resolvedName); + + if (hasResultSet) { + try (ResultSet rs = stmt.getResultSet()) { + ResultSetMetaData meta = rs.getMetaData(); + int colCount = meta.getColumnCount(); + + JsonArray columns = new JsonArray(); + for (int i = 1; i <= colCount; i++) { + JsonObject col = new JsonObject(); + col.put("name", meta.getColumnLabel(i)); + col.put("type", meta.getColumnTypeName(i)); + columns.add(col); + } + root.put("columns", columns); + + JsonArray rows = new JsonArray(); + int rowCount = 0; + while (rs.next()) { + JsonObject row = new JsonObject(); + for (int i = 1; i <= colCount; i++) { + String colName = meta.getColumnLabel(i); + Object val = rs.getObject(i); + if (val == null) { + row.put(colName, null); + } else if (val instanceof Number n) { + row.put(colName, n); + } else if (val instanceof Boolean b) { + row.put(colName, b); + } else { + row.put(colName, String.valueOf(val)); + } + } + rows.add(row); + rowCount++; + } + root.put("rows", rows); + root.put("rowCount", rowCount); + root.put("truncated", rowCount >= maxRows); + } + } else { + int updateCount = stmt.getUpdateCount(); + root.put("updateCount", updateCount); + } + } catch (Exception e) { + long elapsed = watch.taken(); + root.put("status", "error"); + root.put("elapsed", elapsed); + root.put("message", e.getMessage()); + } + + return root; + } + + private static int parseIntOption(Map<String, Object> options, String key, int defaultValue) { + Object val = options.get(key); + if (val instanceof Number n) { + return n.intValue(); + } + if (val instanceof String s && !s.isBlank()) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + // ignore + } + } + return defaultValue; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-sql.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-sql.adoc new file mode 100644 index 000000000000..8c72e6b72140 --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-sql.adoc @@ -0,0 +1,29 @@ + +// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE += camel cmd sql + +Execute SQL query on a DataSource + + +== Usage + +[source,bash] +---- +camel cmd sql [options] +---- + + + +== Options + +[cols="2,5,1,2",options="header"] +|=== +| Option | Description | Default | Type +| `--datasource` | Name of the DataSource bean (auto-detected if only one exists) | | String +| `--max-rows` | Maximum number of rows to return | 100 | int +| `--query,--sql` | The SQL query to execute | | String +| `--query-timeout` | Query timeout in seconds | 30 | int +| `-h,--help` | Display the help and sub-commands | | boolean +|=== + + diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc index b3e799143a74..4e261d317b2d 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc @@ -34,6 +34,7 @@ camel cmd [options] | xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[route-topology] | Display inter-route topology connections | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to endpoints | xref:jbang-commands/camel-jbang-cmd-span.adoc[span] | Display OpenTelemetry spans from running Camel integrations +| xref:jbang-commands/camel-jbang-cmd-sql.adoc[sql] | Execute SQL query on a DataSource | xref:jbang-commands/camel-jbang-cmd-start-group.adoc[start-group] | Start Camel route groups | xref:jbang-commands/camel-jbang-cmd-start-route.adoc[start-route] | Start Camel routes | xref:jbang-commands/camel-jbang-cmd-stop-group.adoc[stop-group] | Stop Camel route groups 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 21688dde8ca6..ae14b3906620 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 @@ -371,6 +371,8 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C doActionReceiveTask(root); } else if ("readme".equals(action)) { doActionReadmeTask(root); + } else if ("sql-query".equals(action)) { + doActionSqlQueryTask(root); } else if ("cli-debug".equals(action)) { doActionCliDebug(root); } @@ -1012,6 +1014,29 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C } } + private void doActionSqlQueryTask(JsonObject root) throws Exception { + DevConsole dc = camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class) + .resolveById("sql-query"); + if (dc != null) { + String sql = root.getStringOrDefault("sql", ""); + String datasource = root.getString("datasource"); + int maxRows = root.getIntegerOrDefault("maxRows", 100); + int queryTimeout = root.getIntegerOrDefault("queryTimeout", 30); + Map<String, Object> args = new HashMap<>(); + args.put("sql", sql); + if (datasource != null) { + args.put("datasource", datasource); + } + args.put("maxRows", String.valueOf(maxRows)); + args.put("queryTimeout", String.valueOf(queryTimeout)); + JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, args); + LOG.trace("Updating output file: {}", outputFile); + IOHelper.writeText(json.toJson(), outputFile); + } else { + IOHelper.writeText("{}", outputFile); + } + } + private void doActionLoadTask(JsonObject root) throws Exception { List<String> files = root.getCollection("source"); boolean restart = root.getBooleanOrDefault("restart", false); 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 19c7b6505642..06b36670a7a6 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 @@ -3,7 +3,7 @@ { "name": "ask", "fullName": "ask", "description": "Ask a question about a running Camel application using AI", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": "--api-key", "description": "API key. 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', or 'anthropic'", "javaType": "LlmClient.ApiType", "type" [...] { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...] { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...] - { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] + { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] }, { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...] { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "To [...] 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 5113540c571f..da28f9ad87bb 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 @@ -121,6 +121,7 @@ public class CamelJBangMain implements Callable<Integer> { .addSubcommand("route-structure", new CommandLine(new CamelRouteStructureAction(this))) .addSubcommand("route-topology", new CommandLine(new CamelRouteTopologyAction(this))) .addSubcommand("send", new CommandLine(new CamelSendAction(this))) + .addSubcommand("sql", new CommandLine(new CamelSqlQueryAction(this))) .addSubcommand("span", new CommandLine(new CamelSpanAction(this))) .addSubcommand("start-group", new CommandLine(new CamelRouteGroupStartAction(this))) .addSubcommand("start-route", new CommandLine(new CamelRouteStartAction(this))) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSqlQueryAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSqlQueryAction.java new file mode 100644 index 000000000000..e383fd9a531b --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSqlQueryAction.java @@ -0,0 +1,173 @@ +/* + * 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.action; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import picocli.CommandLine; + [email protected](name = "sql", + description = "Execute SQL query on a DataSource", sortOptions = false, + showDefaultValues = true, + footer = { + "%nExamples:", + " camel cmd sql myApp --query=\"SELECT * FROM orders\"", + " camel cmd sql myApp --query=\"SELECT * FROM users\" --datasource=myDS --max-rows=50" }) +public class CamelSqlQueryAction extends ActionBaseCommand { + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", + arity = "1") + String name; + + @CommandLine.Option(names = { "--query", "--sql" }, required = true, + description = "The SQL query to execute") + String query; + + @CommandLine.Option(names = { "--datasource" }, + description = "Name of the DataSource bean (auto-detected if only one exists)") + String datasource; + + @CommandLine.Option(names = { "--max-rows" }, defaultValue = "100", + description = "Maximum number of rows to return") + int maxRows = 100; + + @CommandLine.Option(names = { "--query-timeout" }, defaultValue = "30", + description = "Query timeout in seconds") + int queryTimeout = 30; + + public CamelSqlQueryAction(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + List<Long> pids = findPids(name); + if (pids.size() != 1) { + printer().println("Name or pid " + name + " matches " + pids.size() + + " running Camel integrations. Specify a name or PID that matches exactly one."); + return 1; + } + + long pid = pids.get(0); + + Path outputFile = getOutputFile(Long.toString(pid)); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "sql-query"); + root.put("sql", query); + if (datasource != null) { + root.put("datasource", datasource); + } + root.put("maxRows", maxRows); + root.put("queryTimeout", queryTimeout); + + Path actionFile = getActionFile(Long.toString(pid)); + Files.writeString(actionFile, root.toJson()); + + JsonObject jo = getJsonObject(outputFile, (queryTimeout + 10) * 1000L); + try { + if (jo == null) { + printer().println("Timeout waiting for SQL query response"); + return 1; + } + + String status = jo.getString("status"); + if ("error".equals(status)) { + printer().println("Error: " + jo.getString("message")); + return 1; + } + + if (jo.containsKey("updateCount")) { + int updateCount = jo.getInteger("updateCount"); + long elapsed = jo.getLongOrDefault("elapsed", 0); + printer().println("Update count: " + updateCount); + printer().println("Elapsed: " + elapsed + "ms"); + return 0; + } + + JsonArray columns = jo.getCollection("columns"); + JsonArray rows = jo.getCollection("rows"); + if (columns == null || rows == null) { + printer().println("No result data"); + return 0; + } + + // compute column widths + String[] colNames = new String[columns.size()]; + int[] widths = new int[columns.size()]; + for (int i = 0; i < columns.size(); i++) { + JsonObject col = (JsonObject) columns.get(i); + colNames[i] = col.getString("name"); + widths[i] = colNames[i].length(); + } + for (Object rowObj : rows) { + JsonObject row = (JsonObject) rowObj; + for (int i = 0; i < colNames.length; i++) { + Object val = row.get(colNames[i]); + int len = val != null ? String.valueOf(val).length() : 4; + widths[i] = Math.max(widths[i], len); + } + } + + // print header + StringBuilder header = new StringBuilder(); + StringBuilder separator = new StringBuilder(); + for (int i = 0; i < colNames.length; i++) { + if (i > 0) { + header.append(" | "); + separator.append("-+-"); + } + header.append(String.format("%-" + widths[i] + "s", colNames[i])); + separator.append("-".repeat(widths[i])); + } + printer().println(header.toString()); + printer().println(separator.toString()); + + // print rows + for (Object rowObj : rows) { + JsonObject row = (JsonObject) rowObj; + StringBuilder line = new StringBuilder(); + for (int i = 0; i < colNames.length; i++) { + if (i > 0) { + line.append(" | "); + } + Object val = row.get(colNames[i]); + String s = val != null ? String.valueOf(val) : "null"; + line.append(String.format("%-" + widths[i] + "s", s)); + } + printer().println(line.toString()); + } + + int rowCount = jo.getIntegerOrDefault("rowCount", rows.size()); + boolean truncated = jo.getBooleanOrDefault("truncated", false); + long elapsed = jo.getLongOrDefault("elapsed", 0); + printer().println(); + printer().println(rowCount + " row(s)" + (truncated ? " (truncated)" : "") + " in " + elapsed + "ms"); + } finally { + PathUtils.deleteFile(outputFile); + } + + return 0; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java index a23378060634..91caf955c9f4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java @@ -183,6 +183,10 @@ public final class RuntimeHelper { * @return the raw response string, or a timeout message if no response was received */ public static String executeAction(long pid, String action, Consumer<JsonObject> configure) { + return executeAction(pid, action, configure, ACTION_TIMEOUT_MS); + } + + public static String executeAction(long pid, String action, Consumer<JsonObject> configure, long timeoutMs) { String requestId = UUID.randomUUID().toString().substring(0, 8); Path camelDir = CommandLineHelper.getCamelDir(); Path outputFile = camelDir.resolve(pid + "-output-" + requestId + ".json"); @@ -199,7 +203,7 @@ public final class RuntimeHelper { try { StopWatch watch = new StopWatch(); - while (watch.taken() < ACTION_TIMEOUT_MS) { + while (watch.taken() < timeoutMs) { try { Thread.sleep(POLL_INTERVAL_MS); if (Files.exists(outputFile) && outputFile.toFile().length() > 0) { @@ -272,6 +276,25 @@ public final class RuntimeHelper { } } + public static JsonObject executeSqlQuery(long pid, String sql, String datasource, int maxRows, int queryTimeout) { + long timeout = (queryTimeout + 10) * 1000L; + String result = executeAction(pid, "sql-query", root -> { + root.put("sql", sql); + if (datasource != null) { + root.put("datasource", datasource); + } + root.put("maxRows", maxRows); + root.put("queryTimeout", queryTimeout); + }, timeout); + try { + return (JsonObject) Jsoner.deserialize(result); + } catch (Exception e) { + JsonObject wrapper = new JsonObject(); + wrapper.put("result", result); + return wrapper; + } + } + public static JsonObject readStatusFromFile(Path path) { try { if (Files.exists(path) && path.toFile().length() > 0) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index f9985158d512..bd4b5c97243d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -225,6 +225,7 @@ public class CamelMonitor extends CamelCommand { private ProcessTab processTab; private OverviewTab overviewTab; private DataSourceTab dataSourceTab; + private SqlQueryTab sqlQueryTab; // "Switch integration" popup state private boolean showSwitchPopup; @@ -279,6 +280,7 @@ public class CamelMonitor extends CamelCommand { routesTab = new RoutesTab(ctx); consumersTab = new ConsumersTab(ctx); dataSourceTab = new DataSourceTab(ctx); + sqlQueryTab = new SqlQueryTab(ctx); endpointsTab = new EndpointsTab(ctx, metrics); httpTab = new HttpTab(ctx); healthTab = new HealthTab(ctx); @@ -438,7 +440,7 @@ public class CamelMonitor extends CamelCommand { return true; } if (ke.isDown()) { - morePopupState.selectNext(14); + morePopupState.selectNext(15); return true; } int shortcutSel = morePopupShortcut(ke); @@ -461,10 +463,11 @@ public class CamelMonitor extends CamelCommand { case 7 -> inflightTab; case 8 -> memoryTab; case 9 -> metricsTab; - case 10 -> spansTab; - case 11 -> processTab; - case 12 -> startupTab; - case 13 -> threadsTab; + case 10 -> sqlQueryTab; + case 11 -> spansTab; + case 12 -> processTab; + case 13 -> startupTab; + case 14 -> threadsTab; default -> null; }; if (activeMoreTab != null) { @@ -540,7 +543,9 @@ public class CamelMonitor extends CamelCommand { boolean logSearchActive = tabsState.selected() == TAB_LOG && logTab.isSearchInputActive(); boolean spanFilterActive = tabsState.selected() == TAB_MORE && activeMoreTab == spansTab && spansTab.isFilterInputActive(); - boolean textEditing = probeEditing || logSearchActive || spanFilterActive; + boolean sqlInputActive = tabsState.selected() == TAB_MORE && activeMoreTab == sqlQueryTab + && sqlQueryTab.isInputActive(); + boolean textEditing = probeEditing || logSearchActive || spanFilterActive || sqlInputActive; if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) { runner.quit(); return true; @@ -777,6 +782,10 @@ public class CamelMonitor extends CamelCommand { filesBrowser.handlePaste(pe.text()); return true; } + if (activeMoreTab == sqlQueryTab && sqlQueryTab.isInputActive()) { + sqlQueryTab.handlePaste(pe.text()); + return true; + } return false; } @@ -942,6 +951,7 @@ public class CamelMonitor extends CamelCommand { configurationTab.onIntegrationChanged(); consumersTab.onIntegrationChanged(); dataSourceTab.onIntegrationChanged(); + sqlQueryTab.onIntegrationChanged(); circuitBreakerTab.onIntegrationChanged(); inflightTab.onIntegrationChanged(); spansTab.onIntegrationChanged(); @@ -1244,7 +1254,7 @@ public class CamelMonitor extends CamelCommand { private void renderMorePopup(Frame frame, Rect area) { int popupW = 22; - int popupH = 16; + int popupH = 17; // Position just below the "0 More▾" tab label int dividerW = CharWidth.of(" | "); int tabBarX = 0; @@ -1276,6 +1286,7 @@ public class CamelMonitor extends CamelCommand { ListItem.from(Line.from(Span.raw(" "), Span.styled("I", keyStyle), Span.raw("nflight"))), 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(" "), 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"))), @@ -1387,18 +1398,21 @@ public class CamelMonitor extends CamelCommand { if (ke.isChar('e')) { return 9; } - if (ke.isChar('o')) { + if (ke.isChar('q')) { return 10; } - if (ke.isChar('p')) { + if (ke.isChar('o')) { return 11; } - if (ke.isChar('s')) { + if (ke.isChar('p')) { return 12; } - if (ke.isChar('t')) { + if (ke.isChar('s')) { return 13; } + if (ke.isChar('t')) { + return 14; + } return -1; } @@ -2478,7 +2492,8 @@ public class CamelMonitor extends CamelCommand { private static final String[] MORE_TAB_NAMES = { "Beans", "Browse", "Circuit Breaker", "Classpath", "Configuration", - "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "Spans", "Process", "Startup", "Threads" + "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL Query", "Spans", "Process", "Startup", + "Threads" }; String navigateToTab(String tabName) { @@ -2504,10 +2519,11 @@ public class CamelMonitor extends CamelCommand { case 7 -> inflightTab; case 8 -> memoryTab; case 9 -> metricsTab; - case 10 -> spansTab; - case 11 -> processTab; - case 12 -> startupTab; - case 13 -> threadsTab; + case 10 -> sqlQueryTab; + case 11 -> spansTab; + case 12 -> processTab; + case 13 -> startupTab; + case 14 -> threadsTab; default -> null; }; if (activeMoreTab != null) { @@ -2988,6 +3004,19 @@ public class CamelMonitor extends CamelCommand { return RuntimeHelper.sendMessage(pid, endpoint, body, headers); } + JsonObject executeSql(String sql, String datasource, int maxRows, int queryTimeout) { + if (ctx.selectedPid == null) { + return null; + } + long pid; + try { + pid = Long.parseLong(ctx.selectedPid); + } catch (NumberFormatException e) { + return null; + } + return RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows, queryTimeout); + } + String controlIntegration(String action) { if (action == null || action.isBlank()) { return "Error: action is required"; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java new file mode 100644 index 000000000000..d132557af381 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java @@ -0,0 +1,153 @@ +/* + * 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 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.tui.event.KeyEvent; +import dev.tamboui.widgets.Clear; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Title; +import dev.tamboui.widgets.list.ListItem; +import dev.tamboui.widgets.list.ListState; +import dev.tamboui.widgets.list.ListWidget; + +/** + * Reusable input history with popup selector. Stores string entries and renders a centered popup overlay for selection. + */ +class InputHistory { + + private static final int MAX_ENTRIES = 20; + + private final List<String> entries = new ArrayList<>(); + private final ListState listState = new ListState(); + private boolean popupVisible; + private String selected; + + void add(String entry) { + if (entry == null || entry.isBlank()) { + return; + } + entries.remove(entry); + entries.add(0, entry); + if (entries.size() > MAX_ENTRIES) { + entries.remove(entries.size() - 1); + } + } + + boolean isEmpty() { + return entries.isEmpty(); + } + + boolean isPopupVisible() { + return popupVisible; + } + + void showPopup() { + popupVisible = true; + listState.select(0); + selected = null; + } + + void hidePopup() { + popupVisible = false; + selected = null; + } + + /** + * Returns the entry selected on Enter, then resets. Returns null if nothing was selected. + */ + String takeSelected() { + String s = selected; + selected = null; + return s; + } + + boolean handleKeyEvent(KeyEvent ke) { + if (!popupVisible) { + return false; + } + if (ke.isCancel()) { + hidePopup(); + return true; + } + if (ke.isUp()) { + listState.selectPrevious(); + return true; + } + if (ke.isDown()) { + listState.selectNext(entries.size()); + return true; + } + if (ke.isConfirm()) { + Integer sel = listState.selected(); + if (sel != null && sel >= 0 && sel < entries.size()) { + selected = entries.get(sel); + } + popupVisible = false; + return true; + } + return true; + } + + void renderPopup(Frame frame, Rect area, String title) { + if (!popupVisible || entries.isEmpty()) { + return; + } + + int maxLabelLen = 0; + for (String q : entries) { + String oneLine = q.replace('\n', ' '); + maxLabelLen = Math.max(maxLabelLen, oneLine.length() + 4); + } + int popupW = Math.min(area.width() - 4, Math.max(30, maxLabelLen + 4)); + int popupH = Math.min(area.height() - 4, entries.size() + 2); + int x = area.left() + Math.max(0, (area.width() - popupW) / 2); + int y = area.top() + 2; + Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2)); + + frame.renderWidget(Clear.INSTANCE, popup); + + ListItem[] items = new ListItem[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + String label = entries.get(i).replace('\n', ' '); + if (label.length() > popupW - 6) { + label = label.substring(0, popupW - 9) + "..."; + } + items[i] = ListItem.from(Line.from(Span.raw(" " + label))); + } + + ListWidget list = ListWidget.builder() + .items(items) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("") + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .title(Title.from(Line.from(Span.styled(" " + title + " ", Style.EMPTY.fg(Color.YELLOW).bold())))) + .build()) + .build(); + frame.renderStatefulWidget(list, popup, listState); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java new file mode 100644 index 000000000000..a6e9d06591c7 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java @@ -0,0 +1,654 @@ +/* + * 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.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +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.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Title; +import dev.tamboui.widgets.input.TextArea; +import dev.tamboui.widgets.input.TextAreaState; +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.dsl.jbang.core.common.PathUtils; +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 SqlQueryTab implements MonitorTab { + + private final MonitorContext ctx; + private final TextAreaState sqlInput = new TextAreaState(); + private final TableState tableState = new TableState(); + private final AtomicBoolean executing = new AtomicBoolean(); + private final InputHistory sqlHistory = new InputHistory(); + + // datasource selection + private List<String> dsNames = new ArrayList<>(); + private int selectedDs; + private boolean focusOnInput = true; + + // results + private String[] columnNames; + private List<JsonObject> resultRows; + private int rowCount; + private boolean truncated; + private long elapsed; + private String errorMessage; + private Integer updateCount; + + SqlQueryTab(MonitorContext ctx) { + this.ctx = ctx; + } + + boolean isInputActive() { + return focusOnInput; + } + + void handlePaste(String text) { + if (focusOnInput) { + sqlInput.insert(text); + } + } + + @Override + public void onIntegrationChanged() { + IntegrationInfo info = ctx.findSelectedIntegration(); + dsNames.clear(); + selectedDs = 0; + if (info != null) { + for (DataSourceInfo ds : info.dataSources) { + dsNames.add(ds.name); + } + } + clearResults(); + } + + @Override + public void onTabSelected() { + focusOnInput = true; + onIntegrationChanged(); + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (executing.get()) { + return true; + } + + // history popup takes priority + if (sqlHistory.isPopupVisible()) { + sqlHistory.handleKeyEvent(ke); + String selected = sqlHistory.takeSelected(); + if (selected != null) { + sqlInput.clear(); + sqlInput.insert(selected); + } + return true; + } + + if (ke.isCancel()) { + if (!focusOnInput && resultRows != null) { + focusOnInput = true; + return true; + } + return false; + } + + // F5 to execute query + if (focusOnInput && ke.code() == KeyCode.F5) { + executeQuery(); + return true; + } + + // Tab to toggle focus between input and results + if (ke.code() == KeyCode.TAB && resultRows != null && !resultRows.isEmpty()) { + focusOnInput = !focusOnInput; + return true; + } + + // Ctrl+E to open history popup + if (focusOnInput && ke.hasCtrl() && ke.isCharIgnoreCase('e') && !sqlHistory.isEmpty()) { + sqlHistory.showPopup(); + return true; + } + + // Enter adds newline in input mode + if (ke.isConfirm()) { + if (focusOnInput) { + sqlInput.insert('\n'); + return true; + } + return true; + } + + if (focusOnInput) { + // datasource cycling with Ctrl+Left/Right + if (ke.hasCtrl() && ke.isLeft() && dsNames.size() > 1) { + selectedDs = (selectedDs - 1 + dsNames.size()) % dsNames.size(); + return true; + } + if (ke.hasCtrl() && ke.isRight() && dsNames.size() > 1) { + selectedDs = (selectedDs + 1) % dsNames.size(); + return true; + } + + // cursor movement + if (ke.isUp()) { + sqlInput.moveCursorUp(); + return true; + } + if (ke.isDown()) { + sqlInput.moveCursorDown(); + return true; + } + if (ke.isLeft()) { + sqlInput.moveCursorLeft(); + return true; + } + if (ke.isRight()) { + sqlInput.moveCursorRight(); + return true; + } + if (ke.isHome()) { + sqlInput.moveCursorToLineStart(); + return true; + } + if (ke.isEnd()) { + sqlInput.moveCursorToLineEnd(); + return true; + } + if (ke.isDeleteBackward()) { + sqlInput.deleteBackward(); + return true; + } + if (ke.isDeleteForward()) { + sqlInput.deleteForward(); + return true; + } + + // typed character + if (ke.code() == KeyCode.CHAR) { + sqlInput.insert(ke.character()); + return true; + } + + return true; + } + + // results navigation + if (ke.isUp()) { + navigateUp(); + return true; + } + if (ke.isDown()) { + navigateDown(); + return true; + } + + return true; + } + + @Override + public boolean handleEscape() { + if (sqlHistory.isPopupVisible()) { + sqlHistory.hidePopup(); + return true; + } + if (!focusOnInput && resultRows != null) { + focusOnInput = true; + return true; + } + return false; + } + + @Override + public void navigateUp() { + if (!focusOnInput) { + tableState.selectPrevious(); + } + } + + @Override + public void navigateDown() { + if (!focusOnInput && resultRows != null) { + tableState.selectNext(resultRows.size()); + } + } + + @Override + public void render(Frame frame, Rect area) { + if (area.height() < 4) { + return; + } + + // layout: datasource bar (1 line) + SQL input (5 lines) + results (rest) + int inputH = 5; + int dsBarH = dsNames.size() > 1 ? 1 : 0; + int topH = dsBarH + inputH; + + List<Rect> parts = Layout.vertical() + .constraints(Constraint.length(topH), Constraint.min(3)) + .split(area); + + renderInputArea(frame, parts.get(0), dsBarH); + renderResults(frame, parts.get(1)); + + sqlHistory.renderPopup(frame, area, "Query History"); + } + + private void renderInputArea(Frame frame, Rect area, int dsBarH) { + if (dsBarH > 0 && area.height() > 0) { + Rect dsBar = new Rect(area.x(), area.y(), area.width(), 1); + StringBuilder sb = new StringBuilder(" DataSource: "); + for (int i = 0; i < dsNames.size(); i++) { + if (i == selectedDs) { + sb.append("[").append(dsNames.get(i)).append("]"); + } else { + sb.append(" ").append(dsNames.get(i)).append(" "); + } + if (i < dsNames.size() - 1) { + sb.append(" "); + } + } + Style style = focusOnInput ? Style.EMPTY.fg(Color.CYAN) : Style.EMPTY.fg(Color.DARK_GRAY); + Paragraph dsLabel = Paragraph.builder() + .text(Text.from(Line.from(Span.styled(sb.toString(), style)))) + .build(); + frame.renderWidget(dsLabel, dsBar); + } + + Rect inputRect = new Rect(area.x(), area.y() + dsBarH, area.width(), area.height() - dsBarH); + + String title; + if (executing.get()) { + title = " Executing... "; + } else if (errorMessage != null) { + title = " SQL Query (error) "; + } else if (updateCount != null) { + title = String.format(" SQL Query (%d updated, %dms) ", updateCount, elapsed); + } else if (resultRows != null) { + title = String.format(" SQL Query (%d row(s), %dms) ", rowCount, elapsed); + } else { + title = " SQL Query (F5 to execute) "; + } + Style borderStyle = focusOnInput ? Style.EMPTY.fg(Color.CYAN) : Style.EMPTY.fg(Color.DARK_GRAY); + Block inputBlock = Block.builder() + .title(Title.from(title)) + .borderType(BorderType.ROUNDED) + .borderStyle(borderStyle) + .build(); + Rect inner = inputBlock.inner(inputRect); + frame.renderWidget(inputBlock, inputRect); + + TextArea textArea = TextArea.builder() + .cursorStyle(Style.EMPTY.reversed()) + .placeholder("Type SQL query here...") + .build(); + if (focusOnInput) { + textArea.renderWithCursor(inner, frame.buffer(), sqlInput, frame); + } else { + textArea.render(inner, frame.buffer(), sqlInput); + } + } + + private void renderResults(Frame frame, Rect area) { + if (errorMessage != null) { + Block errBlock = Block.builder() + .title(Title.from(" Error ")) + .borderType(BorderType.ROUNDED) + .borderStyle(Style.EMPTY.fg(Color.RED)) + .build(); + Rect inner = errBlock.inner(area); + frame.renderWidget(errBlock, area); + Paragraph errText = Paragraph.builder() + .text(Text.from(Line.from(Span.styled(errorMessage, Style.EMPTY.fg(Color.RED))))) + .build(); + frame.renderWidget(errText, inner); + return; + } + + if (updateCount != null) { + Block ucBlock = Block.builder() + .title(Title.from(" Result ")) + .borderType(BorderType.ROUNDED) + .borderStyle(Style.EMPTY.fg(Color.GREEN)) + .build(); + Rect inner = ucBlock.inner(area); + frame.renderWidget(ucBlock, area); + String msg = String.format("Update count: %d (%dms)", updateCount, elapsed); + Paragraph ucText = Paragraph.builder() + .text(Text.from(Line.from(Span.styled(msg, Style.EMPTY.fg(Color.GREEN))))) + .build(); + frame.renderWidget(ucText, inner); + return; + } + + if (columnNames == null || resultRows == null) { + Block emptyBlock = Block.builder() + .title(Title.from(" Results ")) + .borderType(BorderType.ROUNDED) + .borderStyle(Style.EMPTY.fg(Color.DARK_GRAY)) + .build(); + Rect inner = emptyBlock.inner(area); + frame.renderWidget(emptyBlock, area); + + String hint = dsNames.isEmpty() + ? "No DataSource available" + : "Type a SQL query and press F5 to execute"; + Paragraph hintText = Paragraph.builder() + .text(Text.from(Line.from(Span.styled(hint, Style.EMPTY.fg(Color.DARK_GRAY))))) + .build(); + frame.renderWidget(hintText, inner); + return; + } + + // build result table + String resultTitle = String.format(" %d row(s)%s %dms ", + rowCount, truncated ? " (truncated)" : "", elapsed); + Style tableBorderStyle = !focusOnInput ? Style.EMPTY.fg(Color.CYAN) : Style.EMPTY.fg(Color.DARK_GRAY); + Block tableBlock = Block.builder() + .title(Title.from(resultTitle)) + .borderType(BorderType.ROUNDED) + .borderStyle(tableBorderStyle) + .build(); + + int[] widths = computeColumnWidths(area.width() - 2); + + Row header = Row.from(buildHeaderCells()); + header.style(Style.EMPTY.fg(Color.YELLOW)); + + List<Row> dataRows = new ArrayList<>(); + for (JsonObject row : resultRows) { + List<Cell> cells = new ArrayList<>(); + for (String col : columnNames) { + Object val = row.get(col); + String s = val != null ? String.valueOf(val) : "null"; + Style style = val == null ? Style.EMPTY.fg(Color.DARK_GRAY) : Style.EMPTY.fg(Color.WHITE); + cells.add(Cell.from(Span.styled(s, style))); + } + dataRows.add(Row.from(cells)); + } + + Constraint[] colConstraints = new Constraint[widths.length]; + for (int i = 0; i < widths.length; i++) { + colConstraints[i] = Constraint.length(widths[i]); + } + + Table table = Table.builder() + .header(header) + .rows(dataRows) + .widths(colConstraints) + .block(tableBlock) + .highlightStyle(Style.EMPTY.bg(Color.DARK_GRAY)) + .build(); + frame.renderStatefulWidget(table, area, tableState); + } + + private Cell[] buildHeaderCells() { + Cell[] cells = new Cell[columnNames.length]; + for (int i = 0; i < columnNames.length; i++) { + cells[i] = Cell.from(Span.styled(columnNames[i], Style.EMPTY.fg(Color.YELLOW))); + } + return cells; + } + + private int[] computeColumnWidths(int availableWidth) { + int colCount = columnNames.length; + int[] widths = new int[colCount]; + + for (int i = 0; i < colCount; i++) { + widths[i] = columnNames[i].length(); + } + if (resultRows != null) { + for (JsonObject row : resultRows) { + for (int i = 0; i < colCount; i++) { + Object val = row.get(columnNames[i]); + int len = val != null ? String.valueOf(val).length() : 4; + widths[i] = Math.max(widths[i], len); + } + } + } + + // cap each column to reasonable max + int maxColWidth = Math.max(10, availableWidth / Math.max(1, colCount)); + for (int i = 0; i < colCount; i++) { + widths[i] = Math.min(widths[i] + 2, maxColWidth); + } + return widths; + } + + @Override + public void renderFooter(List<Span> spans) { + if (focusOnInput) { + spans.add(Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Execute ", Style.EMPTY.fg(Color.DARK_GRAY))); + if (!sqlHistory.isEmpty()) { + spans.add(Span.styled(" C-e", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=History ", Style.EMPTY.fg(Color.DARK_GRAY))); + } + if (dsNames.size() > 1) { + spans.add(Span.styled(" C-←→", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=DataSource ", Style.EMPTY.fg(Color.DARK_GRAY))); + } + if (resultRows != null && !resultRows.isEmpty()) { + spans.add(Span.styled(" Tab", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Results ", Style.EMPTY.fg(Color.DARK_GRAY))); + } + } else { + spans.add(Span.styled(" Tab", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Input ", Style.EMPTY.fg(Color.DARK_GRAY))); + spans.add(Span.styled(" ↑↓", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Navigate ", Style.EMPTY.fg(Color.DARK_GRAY))); + } + } + + @Override + public String getHelpText() { + return """ + # SQL Query + + Execute SQL queries against DataSource beans registered in the Camel application. + + ## Usage + - Type a SQL query in the input field and press **F5** to execute + - Use **Enter** for new lines in the query + - Use **Up/Down** arrows to move cursor within the query + - Paste multi-line queries from clipboard + - Use **Ctrl+E** to open query history (select with Enter, dismiss with Esc) + - Use **Tab** to toggle focus between input and results table + - Use **Esc** to return focus to the input field from results + - Use **Ctrl+Left/Right** to switch between DataSources (when multiple exist) + + ## Supported Queries + - SELECT queries return a result table + - INSERT, UPDATE, DELETE return an update count + - Any valid SQL supported by the underlying database + + ## Safety + - Results are limited to 100 rows by default + - Query timeout is 30 seconds by default + - This feature is only available when dev console is enabled (dev profile) + """; + } + + @Override + public JsonObject getTableDataAsJson() { + JsonObject root = new JsonObject(); + if (columnNames != null && resultRows != null) { + JsonArray cols = new JsonArray(); + for (String col : columnNames) { + cols.add(col); + } + root.put("columns", cols); + root.put("rows", new JsonArray(resultRows)); + root.put("rowCount", rowCount); + root.put("truncated", truncated); + root.put("elapsed", elapsed); + } + if (errorMessage != null) { + root.put("error", errorMessage); + } + if (updateCount != null) { + root.put("updateCount", updateCount); + } + return root; + } + + private void executeQuery() { + String sql = sqlInput.text().trim(); + if (sql.isEmpty() || ctx.selectedPid == null || ctx.runner == null) { + return; + } + if (!executing.compareAndSet(false, true)) { + return; + } + + clearResults(); + + sqlHistory.add(sql); + String pid = ctx.selectedPid; + String dsName = dsNames.isEmpty() ? null : dsNames.get(selectedDs); + + ctx.runner.scheduler().execute(() -> { + try { + executeInBackground(pid, sql, dsName); + } finally { + executing.set(false); + } + }); + } + + private void executeInBackground(String pid, String sql, String dsName) { + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "sql-query"); + root.put("sql", sql); + if (dsName != null) { + root.put("datasource", dsName); + } + root.put("maxRows", 100); + root.put("queryTimeout", 30); + + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject jo = pollJsonResponse(outputFile, 35000); + PathUtils.deleteFile(outputFile); + + if (jo == null) { + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + errorMessage = "Timeout waiting for query response"; + }); + } + return; + } + + String status = jo.getString("status"); + if ("error".equals(status)) { + String msg = jo.getString("message"); + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + errorMessage = msg; + elapsed = jo.getLongOrDefault("elapsed", 0); + }); + } + return; + } + + // update count result + if (jo.containsKey("updateCount")) { + int uc = jo.getInteger("updateCount"); + long el = jo.getLongOrDefault("elapsed", 0); + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + updateCount = uc; + elapsed = el; + focusOnInput = true; + }); + } + return; + } + + // SELECT result + JsonArray columns = jo.getCollection("columns"); + JsonArray rows = jo.getCollection("rows"); + if (columns == null || rows == null) { + return; + } + + String[] cols = new String[columns.size()]; + for (int i = 0; i < columns.size(); i++) { + JsonObject col = (JsonObject) columns.get(i); + cols[i] = col.getString("name"); + } + + List<JsonObject> parsedRows = new ArrayList<>(); + for (Object rowObj : rows) { + parsedRows.add((JsonObject) rowObj); + } + + int rc = jo.getIntegerOrDefault("rowCount", parsedRows.size()); + boolean trunc = jo.getBooleanOrDefault("truncated", false); + long el = jo.getLongOrDefault("elapsed", 0); + + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + columnNames = cols; + resultRows = parsedRows; + rowCount = rc; + truncated = trunc; + elapsed = el; + tableState.select(0); + focusOnInput = true; + }); + } + } + + private void clearResults() { + columnNames = null; + resultRows = null; + rowCount = 0; + truncated = false; + elapsed = 0; + errorMessage = null; + updateCount = null; + tableState.select(0); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java index fb871183452f..d3921228b7e6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java @@ -443,6 +443,20 @@ class TuiMcpServer { "body", propDef("string", "Message body to send"), "headers", propDef("string", "Message headers as key=value pairs separated by newlines")), List.of("endpoint"))); + toolList.add(toolDef( + "tui_execute_sql", + "Executes a SQL query against a DataSource in the selected integration. " + + "Returns structured JSON with columns, rows, and metadata for SELECT queries, " + + "or an update count for INSERT/UPDATE/DELETE. " + + "Requires dev console to be enabled in the running application.", + Map.of("query", propDef("string", "The SQL query to execute"), + "datasource", propDef("string", + "Name of the DataSource bean (auto-detected if only one exists)"), + "maxRows", propDef("integer", + "Maximum number of rows to return (default 100)"), + "queryTimeout", propDef("integer", + "Query timeout in seconds (default 30)")), + List.of("query"))); toolList.add(toolDef( "tui_set_log_level", "Changes the runtime log level of the selected integration. " @@ -565,6 +579,7 @@ class TuiMcpServer { case "tui_get_history" -> callGetHistory(args); case "tui_get_topology" -> callGetTopology(); case "tui_send_message" -> callSendMessage(args); + case "tui_execute_sql" -> callExecuteSql(args); case "tui_set_log_level" -> callSetLogLevel(args); case "tui_filter" -> callFilter(args); case "tui_toggle_trace_display" -> callToggleTraceDisplay(args); @@ -1186,6 +1201,21 @@ class TuiMcpServer { return Jsoner.serialize(response); } + private String callExecuteSql(Map<String, Object> args) { + String query = (String) args.get("query"); + if (query == null || query.isBlank()) { + return "Error: query is required"; + } + String datasource = args.get("datasource") instanceof String s ? s : null; + int maxRows = args.get("maxRows") instanceof Number n ? n.intValue() : 100; + int queryTimeout = args.get("queryTimeout") instanceof Number n ? n.intValue() : 30; + JsonObject response = monitor.executeSql(query, datasource, maxRows, queryTimeout); + if (response == null) { + return "Error: no integration selected or PID unavailable"; + } + return Jsoner.serialize(response); + } + private String callSetLogLevel(Map<String, Object> args) { String level = (String) args.get("level"); if (level == null || level.isBlank()) {
