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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 150d8725f45d CAMEL-23706: camel cmd span - Add trace-grouped view and 
ASCII waterfall
150d8725f45d is described below

commit 150d8725f45d3023334c979281a0d522fc0e4630
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Jun 8 17:31:06 2026 +0200

    CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall
    
    Rewrite `camel cmd span` to match TUI SpansTab capabilities:
    
    - Default view 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 and --filter for trace-level columns
    - Increased default limit from 100 to 500
    - Removed span collapsing hack now that core tracer skips redundant
      PROCESS spans (CAMEL-23709 EndpointSending)
    
    Closes #23826
---
 .../pages/jbang-commands/camel-jbang-cmd-span.adoc |   8 +-
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../core/commands/action/CamelSpanAction.java      | 537 ++++++++++++++++++++-
 3 files changed, 525 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..cdeb69bcc772 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,354 @@ 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;
+            }
+        }
+
+        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());
+                    }
+                }
+                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);
         }
-        return spanName.toLowerCase().contains(pattern.toLowerCase());
     }
 
+    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;
+        }
+        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 +589,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 +610,103 @@ 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 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 +730,8 @@ public class CamelSpanAction extends ActionBaseCommand {
         return id;
     }
 
+    // --- Inner classes ---
+
     private static class Row {
         String pid;
         String name;
@@ -265,6 +743,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) {
+    }
 }

Reply via email to