You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/06/08 05:56:48 UTC

(camel) branch CAMEL-23706-cmd-span-waterfall created (now 37a7a72bb2ff)

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

davsclaus pushed a change to branch CAMEL-23706-cmd-span-waterfall
in repository https://gitbox.apache.org/repos/asf/camel.git


      at 37a7a72bb2ff CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall

This branch includes the following new commits:

     new 37a7a72bb2ff CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 01/01: CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-23706-cmd-span-waterfall
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 37a7a72bb2ffaac014943040d68e655d8e5969c8
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 8 07:56:33 2026 +0200

    CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall
    
    Rewrite camel cmd span to match the TUI SpansTab capabilities:
    
    - Default view now shows trace-grouped summaries with ROUTE, FROM,
      SPANS, ROUTES, REMOTE, STATUS, and DURATION columns
    - New --trace=<id> option renders an ASCII waterfall for a specific
      trace with span collapsing, Jansi colors, and duration bars
    - New --flat flag preserves the original per-span list view
    - Updated --sort candidates: trace, route, from, spans, routes,
      status, duration
    - Expanded --filter to match traceId, exchangeId, routeId, and
      remote component names
    - Increased default limit from 100 to 500
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../pages/jbang-commands/camel-jbang-cmd-span.adoc |   8 +-
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../core/commands/action/CamelSpanAction.java      | 549 ++++++++++++++++++++-
 3 files changed, 537 insertions(+), 22 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc
index 0bd6f3271913..583823e775d2 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc
@@ -19,10 +19,12 @@ camel cmd span [options]
 [cols="2,5,1,2",options="header"]
 |===
 | Option | Description | Default | Type
-| `--filter` | Filter spans by name (substring match) |  | String
-| `--limit` | Maximum number of spans to display | 100 | int
+| `--filter` | Filter by trace ID, route, component, or exchange ID (substring match) |  | String
+| `--flat` | Show flat list of individual spans instead of grouped traces |  | boolean
+| `--limit` | Maximum number of spans to display | 500 | int
 | `--logging-color` | Use colored logging | true | boolean
-| `--sort` | Sort by name, duration, or status |  | String
+| `--sort` | Sort by trace, route, from, spans, routes, status, or duration |  | String
+| `--trace` | Show waterfall view for a specific trace ID (substring match) |  | String
 | `-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 cd8c5f4cd094..817a2a70d40c 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/action/CamelSpanAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
index 89c1bd958d30..15131f80cb2f 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
@@ -19,8 +19,14 @@ package org.apache.camel.dsl.jbang.core.commands.action;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 import com.github.freva.asciitable.AsciiTable;
 import com.github.freva.asciitable.Column;
@@ -33,6 +39,7 @@ import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper;
 import org.apache.camel.util.TimeUtils;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
+import org.jline.jansi.Ansi;
 import picocli.CommandLine;
 
 @CommandLine.Command(name = "span",
@@ -41,8 +48,10 @@ import picocli.CommandLine;
                      footer = {
                              "%nExamples:",
                              "  camel cmd span",
-                             "  camel cmd span --limit=50",
-                             "  camel cmd span --filter=direct" })
+                             "  camel cmd span --sort=duration",
+                             "  camel cmd span --trace=4bb73039",
+                             "  camel cmd span --flat",
+                             "  camel cmd span --filter=kafka" })
 public class CamelSpanAction extends ActionBaseCommand {
 
     public static class SortCompletionCandidates implements Iterable<String> {
@@ -52,25 +61,33 @@ public class CamelSpanAction extends ActionBaseCommand {
 
         @Override
         public Iterator<String> iterator() {
-            return List.of("name", "duration", "status").iterator();
+            return List.of("trace", "route", "from", "spans", "routes", "status", "duration").iterator();
         }
     }
 
     @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
     String name = "*";
 
-    @CommandLine.Option(names = { "--limit" }, defaultValue = "100",
+    @CommandLine.Option(names = { "--limit" }, defaultValue = "500",
                         description = "Maximum number of spans to display")
-    int limit = 100;
+    int limit = 500;
 
     @CommandLine.Option(names = { "--filter" },
-                        description = "Filter spans by name (substring match)")
+                        description = "Filter by trace ID, route, component, or exchange ID (substring match)")
     String filter;
 
     @CommandLine.Option(names = { "--sort" }, completionCandidates = SortCompletionCandidates.class,
-                        description = "Sort by name, duration, or status")
+                        description = "Sort by trace, route, from, spans, routes, status, or duration")
     String sort;
 
+    @CommandLine.Option(names = { "--trace" },
+                        description = "Show waterfall view for a specific trace ID (substring match)")
+    String trace;
+
+    @CommandLine.Option(names = { "--flat" },
+                        description = "Show flat list of individual spans instead of grouped traces")
+    boolean flat;
+
     @CommandLine.Option(names = { "--logging-color" }, defaultValue = "true",
                         description = "Use colored logging")
     boolean loggingColor = true;
@@ -158,19 +175,38 @@ public class CamelSpanAction extends ActionBaseCommand {
                 row.status = span.getString("status");
                 Long durationMs = span.getLong("durationMs");
                 row.durationMs = durationMs != null ? durationMs : 0;
-
-                if (filter != null && !matchesFilter(row.spanName, filter)) {
-                    continue;
+                row.routeId = span.getString("routeId");
+                row.processorId = span.getString("processorId");
+                row.startEpochNanos = span.getLongOrDefault("startEpochNanos", 0);
+                row.endEpochNanos = span.getLongOrDefault("endEpochNanos", 0);
+                JsonObject attrsObj = span.getMap("attributes");
+                if (attrsObj != null && !attrsObj.isEmpty()) {
+                    row.attributes = attrsObj;
                 }
 
                 rows.add(row);
             }
 
-            if (sort != null) {
-                rows.sort(this::sortRow);
+            if (trace != null) {
+                printWaterfall(rows, trace);
+            } else if (flat) {
+                if (filter != null) {
+                    rows.removeIf(r -> !matchesFilter(r.spanName, filter));
+                }
+                if (sort != null) {
+                    rows.sort(this::sortRow);
+                }
+                tableSpans(rows);
+            } else {
+                List<TraceSummary> summaries = buildTraceSummaries(rows);
+                if (filter != null) {
+                    summaries.removeIf(ts -> !ts.searchText.contains(filter.toLowerCase()));
+                }
+                if (sort != null) {
+                    summaries.sort((a, b) -> sortTraceSummary(a, b, rows));
+                }
+                tableTraces(summaries);
             }
-
-            tableSpans(rows);
         } else {
             printer().printErr("Response from running Camel with PID " + pid + " not received within 5 seconds");
             return 1;
@@ -180,13 +216,362 @@ public class CamelSpanAction extends ActionBaseCommand {
         return 0;
     }
 
-    private boolean matchesFilter(String spanName, String pattern) {
-        if (spanName == null) {
-            return false;
+    // --- Trace-grouped view ---
+
+    private List<TraceSummary> buildTraceSummaries(List<Row> rows) {
+        Map<String, TraceSummary> byTrace = new LinkedHashMap<>();
+
+        for (Row row : rows) {
+            TraceSummary ts = byTrace.computeIfAbsent(row.traceId, TraceSummary::new);
+            if (isRoot(row)) {
+                ts.rootRouteId = row.routeId;
+                ts.rootName = compactUri(row);
+            }
+            if ("ERROR".equals(row.status)) {
+                ts.hasError = true;
+            }
         }
-        return spanName.toLowerCase().contains(pattern.toLowerCase());
+
+        List<TraceSummary> result = new ArrayList<>(byTrace.values());
+        for (TraceSummary ts : result) {
+            List<Row> traceRows = rows.stream()
+                    .filter(r -> r.traceId.equals(ts.traceId))
+                    .toList();
+            // Fallback root: use earliest span
+            if (ts.rootName == null && !traceRows.isEmpty()) {
+                Row earliest = traceRows.stream()
+                        .min(Comparator.comparingLong(r -> r.startEpochNanos))
+                        .orElse(null);
+                if (earliest != null) {
+                    ts.rootName = compactUri(earliest);
+                    if (ts.rootRouteId == null) {
+                        ts.rootRouteId = earliest.routeId;
+                    }
+                }
+            }
+            long traceStart = Long.MAX_VALUE;
+            long traceEnd = 0;
+            Set<String> routes = new HashSet<>();
+            Set<String> exchangeIds = new HashSet<>();
+            Set<String> remoteSchemes = new LinkedHashSet<>();
+            for (Row r : traceRows) {
+                traceStart = Math.min(traceStart, r.startEpochNanos);
+                traceEnd = Math.max(traceEnd, r.endEpochNanos);
+                if (r.routeId != null) {
+                    routes.add(r.routeId);
+                }
+                if (r.attributes != null) {
+                    Object eid = r.attributes.get("exchangeId");
+                    if (eid != null) {
+                        exchangeIds.add(eid.toString());
+                    }
+                    Object scheme = r.attributes.get("url.scheme");
+                    if (scheme != null && isRemoteScheme(scheme.toString())) {
+                        remoteSchemes.add(scheme.toString());
+                    }
+                }
+                if (!isEventProcess(r)) {
+                    ts.spanCount++;
+                }
+            }
+            ts.totalDurationMs = traceStart < Long.MAX_VALUE ? (traceEnd - traceStart) / 1_000_000 : 0;
+            ts.routeCount = routes.size();
+            ts.remoteComponents = remoteSchemes.isEmpty() ? "" : String.join(",", remoteSchemes);
+            // Build search text
+            StringBuilder sb = new StringBuilder();
+            sb.append(ts.traceId).append(' ');
+            exchangeIds.forEach(e -> sb.append(e).append(' '));
+            routes.forEach(r -> sb.append(r).append(' '));
+            if (!ts.remoteComponents.isEmpty()) {
+                sb.append(ts.remoteComponents);
+            }
+            ts.searchText = sb.toString().toLowerCase();
+        }
+
+        // Default sort: newest first
+        result.sort((a, b) -> {
+            long at = rows.stream()
+                    .filter(r -> r.traceId.equals(a.traceId))
+                    .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+            long bt = rows.stream()
+                    .filter(r -> r.traceId.equals(b.traceId))
+                    .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+            return Long.compare(bt, at);
+        });
+
+        return result;
+    }
+
+    protected void tableTraces(List<TraceSummary> traces) {
+        int tw = terminalWidth();
+        int fixedWidth = 10 + 8 + 8 + 8 + 8 + 12;
+        int borderOverhead = TerminalWidthHelper.noBorderOverhead(8);
+        int remaining = tw - fixedWidth - borderOverhead;
+        int routeWidth = Math.max(10, Math.min(20, remaining / 3));
+        int fromWidth = Math.max(10, Math.min(30, remaining - routeWidth));
+
+        printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, traces, Arrays.asList(
+                new Column().header("TRACE-ID").headerAlign(HorizontalAlign.CENTER)
+                        .with(ts -> shortId(ts.traceId)),
+                new Column().header("ROUTE").dataAlign(HorizontalAlign.LEFT)
+                        .maxWidth(routeWidth, OverflowBehaviour.ELLIPSIS_RIGHT)
+                        .with(ts -> ts.rootRouteId != null ? ts.rootRouteId : ""),
+                new Column().header("FROM").dataAlign(HorizontalAlign.LEFT)
+                        .maxWidth(fromWidth, OverflowBehaviour.ELLIPSIS_RIGHT)
+                        .with(ts -> ts.rootName != null ? ts.rootName : "?"),
+                new Column().header("SPANS").headerAlign(HorizontalAlign.RIGHT)
+                        .dataAlign(HorizontalAlign.RIGHT)
+                        .with(ts -> String.valueOf(ts.spanCount)),
+                new Column().header("ROUTES").headerAlign(HorizontalAlign.RIGHT)
+                        .dataAlign(HorizontalAlign.RIGHT)
+                        .with(ts -> String.valueOf(ts.routeCount)),
+                new Column().header("REMOTE").dataAlign(HorizontalAlign.LEFT)
+                        .with(ts -> ts.remoteComponents.isEmpty() ? "-" : ts.remoteComponents),
+                new Column().header("STATUS").headerAlign(HorizontalAlign.CENTER)
+                        .with(ts -> ts.hasError ? "ERROR" : "OK"),
+                new Column().header("DURATION").headerAlign(HorizontalAlign.RIGHT)
+                        .dataAlign(HorizontalAlign.RIGHT)
+                        .with(ts -> ts.totalDurationMs + "ms"))));
+    }
+
+    // --- Waterfall view ---
+
+    private void printWaterfall(List<Row> allRows, String traceIdMatch) {
+        // Find matching trace by substring
+        String matchedTraceId = null;
+        for (Row r : allRows) {
+            if (r.traceId != null && r.traceId.contains(traceIdMatch)) {
+                matchedTraceId = r.traceId;
+                break;
+            }
+        }
+        if (matchedTraceId == null) {
+            printer().println("No trace found matching '" + traceIdMatch + "'");
+            return;
+        }
+
+        final String tid = matchedTraceId;
+        List<Row> traceRows = allRows.stream()
+                .filter(r -> tid.equals(r.traceId))
+                .sorted(Comparator.comparingLong(r -> r.startEpochNanos))
+                .toList();
+
+        List<WaterfallNode> nodes = buildWaterfallNodes(traceRows);
+        if (nodes.isEmpty()) {
+            printer().println("No spans in trace " + shortId(tid));
+            return;
+        }
+
+        long traceStart = Long.MAX_VALUE;
+        long traceEnd = 0;
+        long minDuration = Long.MAX_VALUE;
+        long maxDuration = 0;
+        for (WaterfallNode n : nodes) {
+            traceStart = Math.min(traceStart, n.row.startEpochNanos);
+            traceEnd = Math.max(traceEnd, n.row.endEpochNanos);
+            if (n.row.durationMs > 0) {
+                minDuration = Math.min(minDuration, n.row.durationMs);
+                maxDuration = Math.max(maxDuration, n.row.durationMs);
+            }
+        }
+        if (minDuration == Long.MAX_VALUE) {
+            minDuration = 0;
+        }
+        long traceDuration = (traceEnd - traceStart) / 1_000_000;
+
+        printer().println();
+        if (loggingColor) {
+            printer().println(Ansi.ansi().bold()
+                    .a("Trace ").a(shortId(tid)).a(" — ")
+                    .a(nodes.size()).a(" spans, ").a(traceDuration).a("ms")
+                    .reset().toString());
+        } else {
+            printer().println("Trace " + shortId(tid) + " — " + nodes.size() + " spans, " + traceDuration + "ms");
+        }
+        printer().println();
+
+        int tw = terminalWidth();
+        int labelWidth = 0;
+        for (WaterfallNode n : nodes) {
+            int indent = n.depth * 2;
+            labelWidth = Math.max(labelWidth, indent + spanLabel(n.row).length());
+        }
+        labelWidth = Math.min(labelWidth + 2, tw / 3);
+        int barMaxWidth = Math.max(10, tw - labelWidth - 12);
+
+        for (WaterfallNode n : nodes) {
+            printWaterfallLine(n, labelWidth, barMaxWidth, traceStart, traceDuration, minDuration, maxDuration);
+        }
+        printer().println();
+    }
+
+    private void printWaterfallLine(
+            WaterfallNode node, int labelWidth, int maxBarWidth,
+            long traceStart, long traceDuration, long minDuration, long maxDuration) {
+
+        String indent = "  ".repeat(node.depth);
+        String label = indent + spanLabel(node.row);
+        if (label.length() > labelWidth) {
+            label = label.substring(0, labelWidth - 1) + "…";
+        } else {
+            label = String.format("%-" + labelWidth + "s", label);
+        }
+
+        long spanStart = node.row.startEpochNanos - traceStart;
+        long spanDuration = node.row.endEpochNanos - node.row.startEpochNanos;
+
+        double offsetRatio = traceDuration > 0 ? (double) (spanStart / 1_000_000) / traceDuration : 0;
+        double widthRatio = traceDuration > 0 ? (double) (spanDuration / 1_000_000) / traceDuration : 0;
+
+        int barOffset = (int) Math.round(offsetRatio * maxBarWidth);
+        int barWidth = Math.max(1, (int) Math.round(widthRatio * maxBarWidth));
+        barOffset = Math.min(barOffset, maxBarWidth - 1);
+        barWidth = Math.min(barWidth, maxBarWidth - barOffset);
+
+        String gap = " ".repeat(barOffset);
+        String bar = "█".repeat(barWidth);
+        String durationStr = node.row.durationMs + "ms";
+        int pad = Math.max(1, 8 - durationStr.length());
+        boolean error = "ERROR".equals(node.row.status);
+
+        if (loggingColor) {
+            Ansi ansi = Ansi.ansi();
+            // Label
+            if (error) {
+                ansi.fgRed().a(label).reset();
+            } else {
+                ansi.fgCyan().a(label).reset();
+            }
+            // Gap + bar
+            ansi.a(gap);
+            if (error) {
+                ansi.fgRed().a(bar).reset();
+            } else {
+                ansi.fg(colorForDuration(node.row.durationMs, minDuration, maxDuration)).a(bar).reset();
+            }
+            // Error tag
+            if (error) {
+                ansi.fgBrightRed().bold().a(" ERR").reset();
+            }
+            // Duration
+            ansi.a(" ".repeat(pad));
+            if (error) {
+                ansi.fgBrightRed().bold().a(durationStr).reset();
+            } else {
+                ansi.bold().a(durationStr).reset();
+            }
+            printer().println(ansi.toString());
+        } else {
+            String errorTag = error ? " ERR" : "";
+            printer().println(label + gap + bar + errorTag + " ".repeat(pad) + durationStr);
+        }
+    }
+
+    private static Ansi.Color colorForDuration(long duration, long minDuration, long maxDuration) {
+        if (maxDuration <= minDuration) {
+            return Ansi.Color.GREEN;
+        }
+        double ratio = (double) (duration - minDuration) / (maxDuration - minDuration);
+        if (ratio < 0.33) {
+            return Ansi.Color.GREEN;
+        } else if (ratio < 0.66) {
+            return Ansi.Color.YELLOW;
+        } else {
+            return Ansi.Color.RED;
+        }
+    }
+
+    private List<WaterfallNode> buildWaterfallNodes(List<Row> traceRows) {
+        if (traceRows.isEmpty()) {
+            return List.of();
+        }
+
+        Map<String, List<Row>> childrenMap = new LinkedHashMap<>();
+        Row root = null;
+        for (Row row : traceRows) {
+            if (isRoot(row)) {
+                root = row;
+            }
+            String parentId = row.parentSpanId;
+            if (parentId != null && !parentId.isEmpty()) {
+                childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(row);
+            }
+        }
+        if (root == null) {
+            root = traceRows.get(0);
+        }
+
+        Set<String> included = new HashSet<>();
+        Map<String, Integer> spanIdToDepth = new LinkedHashMap<>();
+        List<WaterfallNode> result = new ArrayList<>();
+        addToWaterfall(result, root, childrenMap, 0, included, spanIdToDepth);
+
+        // Add orphan spans — insert after their parent when possible
+        boolean changed = true;
+        while (changed) {
+            changed = false;
+            for (Row row : traceRows) {
+                if (included.contains(row.spanId)) {
+                    continue;
+                }
+                int depth = 0;
+                int insertIdx = result.size();
+                if (row.parentSpanId != null && spanIdToDepth.containsKey(row.parentSpanId)) {
+                    depth = spanIdToDepth.get(row.parentSpanId) + 1;
+                    // Find parent position and insert after it and its subtree
+                    for (int i = 0; i < result.size(); i++) {
+                        if (result.get(i).row.spanId.equals(row.parentSpanId)) {
+                            int j = i + 1;
+                            while (j < result.size() && result.get(j).depth > result.get(i).depth) {
+                                j++;
+                            }
+                            insertIdx = j;
+                            break;
+                        }
+                    }
+                }
+                result.add(insertIdx, new WaterfallNode(row, depth));
+                included.add(row.spanId);
+                spanIdToDepth.put(row.spanId, depth);
+                changed = true;
+            }
+        }
+        return result;
     }
 
+    private void addToWaterfall(
+            List<WaterfallNode> result, Row row,
+            Map<String, List<Row>> childrenMap, int depth,
+            Set<String> included, Map<String, Integer> spanIdToDepth) {
+        if (!included.add(row.spanId)) {
+            return;
+        }
+        spanIdToDepth.put(row.spanId, depth);
+
+        List<Row> children = childrenMap.get(row.spanId);
+        // Collapse EVENT_SENT → EVENT_RECEIVED pairs
+        if (isEventSent(row) && !"ERROR".equals(row.status) && children != null && children.size() == 1
+                && isEventReceived(children.get(0))
+                && row.spanName != null && row.spanName.equals(children.get(0).spanName)) {
+            addToWaterfall(result, children.get(0), childrenMap, depth, included, spanIdToDepth);
+            return;
+        }
+        // Collapse EVENT_PROCESS → EVENT_SENT wrapper
+        if (isEventProcess(row) && !"ERROR".equals(row.status) && children != null && children.size() == 1
+                && isEventSent(children.get(0))) {
+            addToWaterfall(result, children.get(0), childrenMap, depth, included, spanIdToDepth);
+            return;
+        }
+        result.add(new WaterfallNode(row, depth));
+        if (children != null) {
+            for (Row child : children) {
+                addToWaterfall(result, child, childrenMap, depth + 1, included, spanIdToDepth);
+            }
+        }
+    }
+
+    // --- Flat span view (original) ---
+
     protected void tableSpans(List<Row> rows) {
         int tw = terminalWidth();
         int fixedWidth = 10 + 10 + 10 + 12 + 8 + 10;
@@ -212,6 +597,8 @@ public class CamelSpanAction extends ActionBaseCommand {
                         .with(r -> r.durationMs + "ms"))));
     }
 
+    // --- Sort ---
+
     protected int sortRow(Row o1, Row o2) {
         String s = sort;
         int negate = 1;
@@ -231,6 +618,107 @@ public class CamelSpanAction extends ActionBaseCommand {
         }
     }
 
+    private int sortTraceSummary(TraceSummary a, TraceSummary b, List<Row> rows) {
+        String s = sort;
+        int negate = 1;
+        if (s.startsWith("-")) {
+            s = s.substring(1);
+            negate = -1;
+        }
+        int cmp = switch (s) {
+            case "route" -> compareNullSafe(a.rootRouteId, b.rootRouteId);
+            case "from" -> compareNullSafe(a.rootName, b.rootName);
+            case "duration" -> Long.compare(b.totalDurationMs, a.totalDurationMs);
+            case "spans" -> Integer.compare(b.spanCount, a.spanCount);
+            case "routes" -> Integer.compare(b.routeCount, a.routeCount);
+            case "status" -> {
+                int as = a.hasError ? 1 : 0;
+                int bs = b.hasError ? 1 : 0;
+                yield Integer.compare(bs, as);
+            }
+            default -> {
+                // "trace" or unknown = newest first
+                long at = rows.stream()
+                        .filter(r -> r.traceId.equals(a.traceId))
+                        .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+                long bt = rows.stream()
+                        .filter(r -> r.traceId.equals(b.traceId))
+                        .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+                yield Long.compare(bt, at);
+            }
+        };
+        return cmp * negate;
+    }
+
+    // --- Helpers ---
+
+    private boolean matchesFilter(String spanName, String pattern) {
+        if (spanName == null) {
+            return false;
+        }
+        return spanName.toLowerCase().contains(pattern.toLowerCase());
+    }
+
+    private static boolean isRoot(Row row) {
+        return row.parentSpanId == null || row.parentSpanId.isEmpty();
+    }
+
+    private static boolean isEventSent(Row row) {
+        return row.attributes != null && "EVENT_SENT".equals(row.attributes.get("op"));
+    }
+
+    private static boolean isEventReceived(Row row) {
+        return row.attributes != null && "EVENT_RECEIVED".equals(row.attributes.get("op"));
+    }
+
+    private static boolean isEventProcess(Row row) {
+        return row.attributes != null && "EVENT_PROCESS".equals(row.attributes.get("op"));
+    }
+
+    private static boolean isRemoteScheme(String scheme) {
+        return scheme != null
+                && !"direct".equals(scheme) && !"seda".equals(scheme)
+                && !"mock".equals(scheme) && !"log".equals(scheme)
+                && !"bean".equals(scheme) && !"class".equals(scheme);
+    }
+
+    private static String spanLabel(Row row) {
+        if (row.attributes != null) {
+            Object uri = row.attributes.get("camel.uri");
+            if (uri != null) {
+                String label = uri.toString();
+                if (row.routeId != null) {
+                    label += " (" + row.routeId + ")";
+                }
+                return label;
+            }
+        }
+        if (row.processorId != null) {
+            String label = row.processorId;
+            if (row.routeId != null) {
+                label += " (" + row.routeId + ")";
+            }
+            return label;
+        }
+        return row.spanName != null ? row.spanName : "";
+    }
+
+    private static String compactUri(Row row) {
+        if (row.attributes != null) {
+            Object uri = row.attributes.get("camel.uri");
+            if (uri != null) {
+                String s = uri.toString();
+                s = s.replace("://", ":");
+                int q = s.indexOf('?');
+                if (q > 0) {
+                    s = s.substring(0, q);
+                }
+                return s;
+            }
+        }
+        return row.spanName;
+    }
+
     private static int compareNullSafe(String a, String b) {
         if (a == null && b == null) {
             return 0;
@@ -254,6 +742,8 @@ public class CamelSpanAction extends ActionBaseCommand {
         return id;
     }
 
+    // --- Inner classes ---
+
     private static class Row {
         String pid;
         String name;
@@ -265,6 +755,29 @@ public class CamelSpanAction extends ActionBaseCommand {
         String kind;
         String status;
         long durationMs;
+        String routeId;
+        String processorId;
+        long startEpochNanos;
+        long endEpochNanos;
+        Map<String, Object> attributes;
     }
 
+    private static class TraceSummary {
+        final String traceId;
+        String rootRouteId;
+        String rootName;
+        int spanCount;
+        long totalDurationMs;
+        boolean hasError;
+        int routeCount;
+        String remoteComponents = "";
+        String searchText = "";
+
+        TraceSummary(String traceId) {
+            this.traceId = traceId;
+        }
+    }
+
+    private record WaterfallNode(Row row, int depth) {
+    }
 }