This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch 
CAMEL-23631-route-diagram-highlight-error-path
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 11c99d7fa8f94ab4c9099e738abd88cc36a2754a
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed May 27 22:59:25 2026 +0200

    CAMEL-23631: Add --diagram option to camel get error
    
    Co-Authored-By: Claude <[email protected]>
---
 .../jbang-commands/camel-jbang-get-error.adoc      |   2 +
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../dsl/jbang/core/commands/process/ListError.java | 213 +++++++++++++++++++++
 3 files changed, 216 insertions(+), 1 deletion(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc
index ecd48c020df4..c25540dcf88a 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-error.adoc
@@ -21,6 +21,7 @@ camel get error [options]
 | Option | Description | Default | Type
 | `--ago` | Filter by time window, e.g. 60s, 5m, 1h |  | String
 | `--detail` | Show full details of each error entry |  | boolean
+| `--diagram` | Display a route diagram with the error path highlighted |  | 
boolean
 | `--exception` | Filter by exception type (substring match) |  | String
 | `--handled` | Filter by handled status (true or false) |  | String
 | `--id` | Filter by exchange ID |  | String
@@ -31,6 +32,7 @@ camel get error [options]
 | `--route` | Filter by route ID |  | String
 | `--show` | Comma-separated detail sections to show: body, headers, 
properties, variables, history, stackTrace, or 'all' for all sections |  | 
String
 | `--sort` | Sort by pid, name or age | pid | String
+| `--theme` | Diagram color theme (ascii, unicode, dark, light, transparent, 
or custom) | unicode | String
 | `--watch` | Execute periodically and showing output fullscreen |  | boolean
 | `-h,--help` | Display the help and sub-commands |  | boolean
 |===
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 afafb3cab311..9c3a28b41d18 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
@@ -14,7 +14,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/process/ListError.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
index b1ae97222215..88f8df312dc9 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
@@ -16,8 +16,12 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.process;
 
+import java.awt.image.BufferedImage;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -26,14 +30,28 @@ 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.diagram.RouteDiagramAsciiRenderer;
+import org.apache.camel.diagram.RouteDiagramHelper;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeLabelMode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.diagram.RouteDiagramRenderer;
+import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors;
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
 import org.apache.camel.dsl.jbang.core.commands.action.MessageTableHelper;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
 import org.apache.camel.dsl.jbang.core.common.PidNameAgeCompletionCandidates;
 import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+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;
 import org.apache.camel.util.json.Jsoner;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import org.jline.terminal.impl.TerminalGraphics;
+import org.jline.terminal.impl.TerminalGraphicsManager;
 import picocli.CommandLine;
 import picocli.CommandLine.Command;
 
@@ -87,6 +105,14 @@ public class ListError extends ProcessWatchCommand {
                         description = "Show only the last (newest) error with 
full details")
     boolean last;
 
+    @CommandLine.Option(names = { "--diagram" },
+                        description = "Display a route diagram with the error 
path highlighted")
+    boolean diagram;
+
+    @CommandLine.Option(names = { "--theme" }, defaultValue = "unicode",
+                        description = "Diagram color theme (ascii, unicode, 
dark, light, transparent, or custom)")
+    String theme = "unicode";
+
     public ListError(CamelJBangMain main) {
         super(main);
     }
@@ -98,6 +124,10 @@ public class ListError extends ProcessWatchCommand {
     public Integer doProcessWatchCall() throws Exception {
         List<Row> rows = new ArrayList<>();
 
+        if (diagram) {
+            System.setProperty("java.awt.headless", "true");
+            last = true;
+        }
         if (last) {
             limit = 1;
             detail = true;
@@ -187,6 +217,16 @@ public class ListError extends ProcessWatchCommand {
         }
 
         if (!display.isEmpty()) {
+            // diagram mode: render route diagram with error path highlighted
+            if (diagram) {
+                Row row = display.get(0);
+                if (row.messageHistory == null || row.messageHistory.length == 
0) {
+                    printer().println("No message history available for this 
error (enable message history in Camel)");
+                    return 1;
+                }
+                return doDiagramCall(Long.parseLong(row.pid), row);
+            }
+
             if (jsonOutput) {
                 // dump the raw JSON from the error entries
                 printer().println(Jsoner.serialize(display.stream().map(r -> 
r.rawJson).collect(Collectors.toList())));
@@ -263,6 +303,171 @@ public class ListError extends ProcessWatchCommand {
         return 0;
     }
 
+    private Integer doDiagramCall(long pid, Row row) throws Exception {
+        // extract node IDs and route order from message history
+        Set<String> nodeIds = new LinkedHashSet<>();
+        Set<String> routeOrderSet = new LinkedHashSet<>();
+        for (String step : row.messageHistory) {
+            // format: routeId[nodeId] or routeId[nodeId] (elapsed ms)
+            int open = step.indexOf('[');
+            int close = step.indexOf(']');
+            if (open > 0 && close > open) {
+                String routeId = step.substring(0, open);
+                String nodeId = step.substring(open + 1, close);
+                if (nodeId != null && !nodeId.isEmpty()) {
+                    nodeIds.add(nodeId);
+                }
+                if (routeId != null && !routeId.isEmpty()) {
+                    routeOrderSet.add(routeId);
+                }
+            }
+        }
+
+        if (nodeIds.isEmpty()) {
+            printer().println("No node IDs found in error message history");
+            return 1;
+        }
+
+        RouteDiagramHelper.HighlightStyle hlStyle = 
RouteDiagramHelper.HighlightStyle.FAIL;
+
+        // request route structure from the running process
+        String pidStr = Long.toString(pid);
+        Path outputFile = getOutputFile(pidStr);
+        PathUtils.deleteFile(outputFile);
+
+        JsonObject action = new JsonObject();
+        action.put("action", "route-structure");
+        action.put("filter", "*");
+        action.put("brief", false);
+        action.put("metric", false);
+        Path actionFile = getActionFile(pidStr);
+        try {
+            Files.writeString(actionFile, action.toJson());
+        } catch (Exception e) {
+            // ignore
+        }
+
+        JsonObject structureJson = waitForJsonResponse(outputFile);
+        if (structureJson == null) {
+            printer().println("Response from running Camel with PID " + pid + 
" not received within 5 seconds");
+            return 1;
+        }
+
+        try {
+            List<RouteInfo> routes = 
RouteDiagramHelper.parseRoutes(structureJson);
+            if (routes.isEmpty()) {
+                printer().println("No routes found");
+                return 1;
+            }
+
+            // add structural parent node IDs for each route that has 
highlighted nodes
+            for (RouteInfo ri : routes) {
+                boolean routeHasHighlight = ri.nodes.stream().anyMatch(n -> 
n.id != null && nodeIds.contains(n.id));
+                if (routeHasHighlight) {
+                    addParentNodes(ri.nodes, nodeIds);
+                }
+            }
+
+            // filter and order routes by highlighted path
+            RouteDiagramHelper.HighlightInfo highlightInfo
+                    = new RouteDiagramHelper.HighlightInfo(nodeIds, new 
ArrayList<>(routeOrderSet), hlStyle);
+            routes = RouteDiagramHelper.filterAndOrderRoutes(routes, 
highlightInfo);
+            if (routes.isEmpty()) {
+                printer().println("No routes contain highlighted nodes from 
error history");
+                return 1;
+            }
+
+            // layout
+            RouteDiagramLayoutEngine engine
+                    = new RouteDiagramLayoutEngine(180, 12, 
NodeLabelMode.BOTH);
+
+            List<LayoutRoute> layoutRoutes = new ArrayList<>();
+            int currentY = RouteDiagramLayoutEngine.PADDING;
+            for (RouteInfo ri : routes) {
+                LayoutRoute lr = engine.layoutRoute(ri, currentY);
+                layoutRoutes.add(lr);
+                currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
+            }
+
+            boolean textMode = isTextTheme();
+            if (textMode) {
+                RouteDiagramAsciiRenderer asciiRenderer
+                        = new RouteDiagramAsciiRenderer(engine.getNodeWidth(), 
isUnicodeTheme());
+                String ascii = asciiRenderer.renderDiagramAnsi(layoutRoutes, 
currentY, nodeIds, hlStyle);
+                printer().println(ascii);
+            } else {
+                DiagramColors colors = DiagramColors.parse(theme);
+                RouteDiagramRenderer renderer = new RouteDiagramRenderer(
+                        engine.getNodeWidth(), 12 * 
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(), false);
+
+                BufferedImage image;
+                try {
+                    image = renderer.renderDiagram(layoutRoutes, currentY, 
colors, nodeIds, hlStyle);
+                } catch (IllegalStateException e) {
+                    printer().println(e.getMessage());
+                    return 1;
+                }
+
+                try (Terminal terminal = 
TerminalBuilder.builder().system(true).build()) {
+                    TerminalGraphics tg = 
TerminalGraphicsManager.getBestProtocol(terminal).orElse(null);
+                    if (tg == null) {
+                        printer().println(
+                                "Terminal does not support graphics. Use 
--theme=ascii or --theme=unicode for text output.");
+                        return 1;
+                    }
+                    TerminalGraphics.ImageOptions opts = new 
TerminalGraphics.ImageOptions().preserveAspectRatio(true);
+                    tg.displayImage(terminal, image, opts);
+                    terminal.writer().println();
+                    terminal.flush();
+                }
+            }
+            return 0;
+        } finally {
+            PathUtils.deleteFile(outputFile);
+        }
+    }
+
+    private static void addParentNodes(List<RouteDiagramLayoutEngine.NodeInfo> 
nodes, Set<String> nodeIds) {
+        for (int i = 0; i < nodes.size(); i++) {
+            RouteDiagramLayoutEngine.NodeInfo node = nodes.get(i);
+            if (node.id == null || nodeIds.contains(node.id)) {
+                continue;
+            }
+            boolean hasHighlightedChild = false;
+            for (int j = i + 1; j < nodes.size(); j++) {
+                RouteDiagramLayoutEngine.NodeInfo child = nodes.get(j);
+                if (child.level <= node.level) {
+                    break;
+                }
+                if (child.id != null && nodeIds.contains(child.id)) {
+                    hasHighlightedChild = true;
+                    break;
+                }
+            }
+            if (hasHighlightedChild) {
+                nodeIds.add(node.id);
+            }
+        }
+    }
+
+    private static JsonObject waitForJsonResponse(Path outputFile) {
+        StopWatch watch = new StopWatch();
+        while (watch.taken() < 5000) {
+            try {
+                Thread.sleep(100);
+                if (Files.exists(outputFile) && outputFile.toFile().length() > 
0) {
+                    String text = Files.readString(outputFile);
+                    return (JsonObject) Jsoner.deserialize(text);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            } catch (Exception e) {
+                // ignore
+            }
+        }
+        return null;
+    }
+
     private boolean matchesFilters(Row row) {
         if (id != null && !id.equals(row.exchangeId)) {
             return false;
@@ -298,6 +503,14 @@ public class ListError extends ProcessWatchCommand {
         return "";
     }
 
+    private boolean isTextTheme() {
+        return "ascii".equalsIgnoreCase(theme) || 
"unicode".equalsIgnoreCase(theme);
+    }
+
+    private boolean isUnicodeTheme() {
+        return "unicode".equalsIgnoreCase(theme);
+    }
+
     private static String shortExceptionType(String type) {
         if (type == null) {
             return "";

Reply via email to