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

davsclaus pushed a commit to branch feature/CAMEL-23706-tui-otel-spans
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 9638b977cc26df95f205b3ccb0c6a2b1245a229c
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Jun 7 14:24:11 2026 +0200

    CAMEL-23706: TUI - Add OTel Spans tab with Jaeger-style waterfall view
    
    Co-Authored-By: Claude <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  64 ++-
 .../dsl/jbang/core/commands/tui/SpanEntry.java     |  62 +++
 .../dsl/jbang/core/commands/tui/SpansTab.java      | 510 +++++++++++++++++++++
 3 files changed, 631 insertions(+), 5 deletions(-)

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 a53e2f560922..f6430f61a742 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
@@ -151,6 +151,8 @@ public class CamelMonitor extends CamelCommand {
     // Trace/history data — shared between CamelMonitor and tabs
     private final AtomicReference<List<TraceEntry>> traces = new 
AtomicReference<>(Collections.emptyList());
     private final Map<String, Long> traceFilePositions = new 
ConcurrentHashMap<>();
+    // OTel span data — shared between CamelMonitor and SpansTab
+    private final AtomicReference<List<SpanEntry>> otelSpans = new 
AtomicReference<>(List.of());
 
     // selectedPid is stored on ctx (MonitorContext) so tabs can access it
 
@@ -217,6 +219,7 @@ public class CamelMonitor extends CamelCommand {
     private InflightTab inflightTab;
     private MemoryTab memoryTab;
     private ThreadsTab threadsTab;
+    private SpansTab spansTab;
     private OverviewTab overviewTab;
 
     // "Switch integration" popup state
@@ -285,6 +288,7 @@ public class CamelMonitor extends CamelCommand {
         inflightTab = new InflightTab(ctx);
         memoryTab = new MemoryTab(ctx, metrics);
         threadsTab = new ThreadsTab(ctx);
+        spansTab = new SpansTab(ctx, otelSpans);
         overviewTab = new OverviewTab(
                 ctx, metrics, stoppingPids,
                 this::resetIntegrationTabState);
@@ -413,7 +417,7 @@ public class CamelMonitor extends CamelCommand {
                 return true;
             }
             if (ke.isDown()) {
-                morePopupState.selectNext(11);
+                morePopupState.selectNext(12);
                 return true;
             }
             int shortcutSel = morePopupShortcut(ke);
@@ -435,8 +439,9 @@ public class CamelMonitor extends CamelCommand {
                         case 6 -> inflightTab;
                         case 7 -> memoryTab;
                         case 8 -> metricsTab;
-                        case 9 -> startupTab;
-                        case 10 -> threadsTab;
+                        case 9 -> spansTab;
+                        case 10 -> startupTab;
+                        case 11 -> threadsTab;
                         default -> null;
                     };
                     if (activeMoreTab != null) {
@@ -1215,6 +1220,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("  "), Span.styled("O", 
keyStyle), Span.raw("Tel Spans"))),
                 ListItem.from(Line.from(Span.raw("  "), Span.styled("S", 
keyStyle), Span.raw("tartup"))),
                 ListItem.from(Line.from(Span.raw("  "), Span.styled("T", 
keyStyle), Span.raw("hreads"))),
         };
@@ -1321,12 +1327,15 @@ public class CamelMonitor extends CamelCommand {
         if (ke.isChar('e')) {
             return 8;
         }
-        if (ke.isChar('s')) {
+        if (ke.isChar('o')) {
             return 9;
         }
-        if (ke.isChar('t')) {
+        if (ke.isChar('s')) {
             return 10;
         }
+        if (ke.isChar('t')) {
+            return 11;
+        }
         return -1;
     }
 
@@ -1917,6 +1926,51 @@ public class CamelMonitor extends CamelCommand {
             }
             refreshTraceData(selectedPids);
         }
+        if (tabsState.selected() == TAB_MORE && activeMoreTab == spansTab
+                && ctx.selectedPid != null && spansTab.spanRefreshRequested) {
+            spansTab.spanRefreshRequested = false;
+            refreshSpanData();
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void refreshSpanData() {
+        String pid = ctx.selectedPid;
+        if (pid == null) {
+            return;
+        }
+        try {
+            // Send action to request span data
+            Path outputFile = ctx.getOutputFile(pid);
+            PathUtils.deleteFile(outputFile);
+
+            JsonObject action = new JsonObject();
+            action.put("action", "span");
+            action.put("dump", "true");
+            action.put("limit", "500");
+            Path actionFile = ctx.getActionFile(pid);
+            PathUtils.writeTextSafely(action.toJson(), actionFile);
+
+            // Poll for response
+            JsonObject response = MonitorContext.pollJsonResponse(outputFile, 
3000);
+            if (response != null) {
+                Boolean enabled = response.getBoolean("enabled");
+                if (enabled != null && enabled) {
+                    JsonArray arr = response.getCollection("spans");
+                    if (arr != null) {
+                        List<SpanEntry> entries = new ArrayList<>();
+                        for (int i = 0; i < arr.size(); i++) {
+                            JsonObject spanObj = (JsonObject) arr.get(i);
+                            entries.add(SpanEntry.fromJson(spanObj));
+                        }
+                        otelSpans.set(entries);
+                    }
+                }
+                PathUtils.deleteFile(outputFile);
+            }
+        } catch (Exception e) {
+            // ignore
+        }
     }
 
     @SuppressWarnings("unchecked")
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpanEntry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpanEntry.java
new file mode 100644
index 000000000000..519dae5d8fea
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpanEntry.java
@@ -0,0 +1,62 @@
+/*
+ * 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.Map;
+
+import org.apache.camel.util.json.JsonObject;
+
+record SpanEntry(
+        String traceId,
+        String spanId,
+        String parentSpanId,
+        String name,
+        String kind,
+        String status,
+        long startEpochNanos,
+        long endEpochNanos,
+        long durationMs,
+        Map<String, Object> attributes) {
+
+    @SuppressWarnings("unchecked")
+    static SpanEntry fromJson(JsonObject jo) {
+        Map<String, Object> attrs = null;
+        JsonObject attrsObj = jo.getMap("attributes");
+        if (attrsObj != null && !attrsObj.isEmpty()) {
+            attrs = attrsObj;
+        }
+        return new SpanEntry(
+                jo.getString("traceId"),
+                jo.getString("spanId"),
+                jo.getString("parentSpanId"),
+                jo.getString("name"),
+                jo.getString("kind"),
+                jo.getString("status"),
+                jo.getLongOrDefault("startEpochNanos", 0),
+                jo.getLongOrDefault("endEpochNanos", 0),
+                jo.getLongOrDefault("durationMs", 0),
+                attrs);
+    }
+
+    boolean isRoot() {
+        return parentSpanId == null || parentSpanId.isEmpty();
+    }
+
+    boolean isError() {
+        return "ERROR".equals(status);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
new file mode 100644
index 000000000000..9c593fa1eef8
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
@@ -0,0 +1,510 @@
+/*
+ * 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.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+
+class SpansTab implements MonitorTab {
+
+    private final MonitorContext ctx;
+    private final AtomicReference<List<SpanEntry>> spans;
+
+    private final TableState traceListState = new TableState();
+    private final ScrollbarState waterfallScrollState = new ScrollbarState();
+
+    private boolean waterfallView;
+    private String selectedTraceId;
+    private int waterfallScroll;
+    private int waterfallSelected;
+
+    boolean spanRefreshRequested;
+
+    SpansTab(MonitorContext ctx, AtomicReference<List<SpanEntry>> spans) {
+        this.ctx = ctx;
+        this.spans = spans;
+    }
+
+    @Override
+    public void onTabSelected() {
+        spanRefreshRequested = true;
+    }
+
+    @Override
+    public void onIntegrationChanged() {
+        spanRefreshRequested = true;
+        waterfallView = false;
+        selectedTraceId = null;
+    }
+
+    @Override
+    public boolean handleKeyEvent(KeyEvent ke) {
+        if (waterfallView) {
+            return handleWaterfallKeys(ke);
+        }
+        if (ke.isConfirm()) {
+            Integer sel = traceListState.selected();
+            List<TraceSummary> summaries = buildTraceSummaries();
+            if (sel != null && sel >= 0 && sel < summaries.size()) {
+                selectedTraceId = summaries.get(sel).traceId;
+                waterfallView = true;
+                waterfallScroll = 0;
+                waterfallSelected = 0;
+            }
+            return true;
+        }
+        if (ke.isChar('r') || ke.isChar('R')) {
+            spanRefreshRequested = true;
+            return true;
+        }
+        return false;
+    }
+
+    private boolean handleWaterfallKeys(KeyEvent ke) {
+        if (ke.isUp()) {
+            if (waterfallSelected > 0) {
+                waterfallSelected--;
+            }
+            return true;
+        }
+        if (ke.isDown()) {
+            List<WaterfallNode> nodes = buildWaterfallNodes(selectedTraceId);
+            if (waterfallSelected < nodes.size() - 1) {
+                waterfallSelected++;
+            }
+            return true;
+        }
+        if (ke.isChar('r') || ke.isChar('R')) {
+            spanRefreshRequested = true;
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public boolean handleEscape() {
+        if (waterfallView) {
+            waterfallView = false;
+            selectedTraceId = null;
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public void navigateUp() {
+        if (!waterfallView) {
+            traceListState.selectPrevious();
+        }
+    }
+
+    @Override
+    public void navigateDown() {
+        if (!waterfallView) {
+            traceListState.selectNext(buildTraceSummaries().size());
+        }
+    }
+
+    @Override
+    public void render(Frame frame, Rect area) {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info == null) {
+            MonitorContext.renderNoSelection(frame, area);
+            return;
+        }
+
+        List<SpanEntry> currentSpans = spans.get();
+        if (currentSpans.isEmpty()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(
+                                    Span.styled("  No OTel spans captured. Use 
--observe flag when running.",
+                                            Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED)
+                                    .title(" OTel Spans ").build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        if (waterfallView && selectedTraceId != null) {
+            renderWaterfallView(frame, area);
+        } else {
+            renderTraceList(frame, area);
+        }
+    }
+
+    private void renderTraceList(Frame frame, Rect area) {
+        List<TraceSummary> summaries = buildTraceSummaries();
+
+        List<Row> rows = new ArrayList<>();
+        for (TraceSummary ts : summaries) {
+            Style statusStyle;
+            if (ts.hasError) {
+                statusStyle = Style.EMPTY.fg(Color.LIGHT_RED);
+            } else {
+                statusStyle = Style.EMPTY.fg(Color.GREEN);
+            }
+
+            rows.add(Row.from(
+                    Cell.from(shortId(ts.traceId)),
+                    Cell.from(String.valueOf(ts.spanCount)),
+                    Cell.from(ts.rootName != null ? ts.rootName : "?"),
+                    Cell.from(Span.styled(ts.hasError ? "ERROR" : "OK", 
statusStyle)),
+                    Cell.from(ts.totalDurationMs + "ms"),
+                    Cell.from(String.valueOf(ts.spanCount))));
+        }
+
+        String title = String.format(" OTel Traces — %d traces, %d spans ", 
summaries.size(), spans.get().size());
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled("TRACE-ID", 
Style.EMPTY.fg(Color.YELLOW).bold())),
+                        Cell.from(Span.styled("SPANS", 
Style.EMPTY.fg(Color.YELLOW).bold())),
+                        Cell.from(Span.styled("ROOT", 
Style.EMPTY.fg(Color.YELLOW).bold())),
+                        Cell.from(Span.styled("STATUS", 
Style.EMPTY.fg(Color.YELLOW).bold())),
+                        Cell.from(Span.styled("DURATION", 
Style.EMPTY.fg(Color.YELLOW).bold())),
+                        Cell.from(Span.styled("DEPTH", 
Style.EMPTY.fg(Color.YELLOW).bold()))))
+                .widths(
+                        Constraint.length(10),
+                        Constraint.length(7),
+                        Constraint.fill(),
+                        Constraint.length(8),
+                        Constraint.length(12),
+                        Constraint.length(7))
+                .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+                
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                .build();
+        frame.renderStatefulWidget(table, area, traceListState);
+    }
+
+    private void renderWaterfallView(Frame frame, Rect area) {
+        List<WaterfallNode> nodes = buildWaterfallNodes(selectedTraceId);
+        if (nodes.isEmpty()) {
+            waterfallView = false;
+            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.span.startEpochNanos());
+            traceEnd = Math.max(traceEnd, n.span.endEpochNanos());
+            if (n.span.durationMs() > 0) {
+                minDuration = Math.min(minDuration, n.span.durationMs());
+                maxDuration = Math.max(maxDuration, n.span.durationMs());
+            }
+        }
+        if (minDuration == Long.MAX_VALUE) {
+            minDuration = 0;
+        }
+        long traceDuration = (traceEnd - traceStart) / 1_000_000;
+
+        // Split: waterfall top, detail bottom
+        List<Rect> chunks = Layout.vertical()
+                .constraints(Constraint.fill(), Constraint.length(6))
+                .split(area);
+
+        renderWaterfall(frame, chunks.get(0), nodes, traceStart, 
traceDuration, minDuration, maxDuration);
+        renderSpanDetail(frame, chunks.get(1), nodes);
+    }
+
+    private void renderWaterfall(
+            Frame frame, Rect area, List<WaterfallNode> nodes,
+            long traceStart, long traceDuration, long minDuration, long 
maxDuration) {
+
+        String title = String.format(" Trace %s — %d spans, %dms ",
+                shortId(selectedTraceId), nodes.size(), traceDuration);
+        Block block = Block.builder()
+                .borderType(BorderType.ROUNDED)
+                .title(title)
+                .build();
+        Rect inner = block.inner(area);
+        frame.renderWidget(block, area);
+
+        if (inner.height() < 1 || inner.width() < 20) {
+            return;
+        }
+
+        int visibleLines = inner.height();
+        int maxScroll = Math.max(0, nodes.size() - visibleLines);
+        waterfallScroll = Math.min(waterfallScroll, maxScroll);
+
+        // Auto-scroll to keep selection visible
+        if (waterfallSelected < waterfallScroll) {
+            waterfallScroll = waterfallSelected;
+        } else if (waterfallSelected >= waterfallScroll + visibleLines) {
+            waterfallScroll = waterfallSelected - visibleLines + 1;
+        }
+
+        int labelWidth = 0;
+        for (WaterfallNode n : nodes) {
+            int indent = n.depth * 2;
+            labelWidth = Math.max(labelWidth, indent + n.span.name().length());
+        }
+        labelWidth = Math.min(labelWidth + 2, inner.width() / 3);
+
+        int barMaxWidth = Math.max(10, inner.width() - labelWidth - 12);
+
+        int end = Math.min(waterfallScroll + visibleLines, nodes.size());
+        List<Line> lines = new ArrayList<>();
+        for (int i = waterfallScroll; i < end; i++) {
+            WaterfallNode n = nodes.get(i);
+            boolean selected = i == waterfallSelected;
+            lines.add(renderWaterfallLine(n, labelWidth, barMaxWidth,
+                    traceStart, traceDuration, minDuration, maxDuration, 
selected));
+        }
+
+        List<Rect> hChunks = Layout.horizontal()
+                .constraints(Constraint.fill(), Constraint.length(1))
+                .split(inner);
+
+        frame.renderWidget(Paragraph.builder().text(Text.from(lines)).build(), 
hChunks.get(0));
+
+        if (nodes.size() > visibleLines) {
+            waterfallScrollState
+                    .contentLength(nodes.size())
+                    .viewportContentLength(visibleLines)
+                    .position(waterfallScroll);
+            frame.renderStatefulWidget(Scrollbar.builder().build(), 
hChunks.get(1), waterfallScrollState);
+        }
+    }
+
+    private static Line renderWaterfallLine(
+            WaterfallNode node, int labelWidth, int maxBarWidth,
+            long traceStart, long traceDuration, long minDuration, long 
maxDuration,
+            boolean selected) {
+        String indicator = selected ? "▸ " : "  ";
+        String indent = "  ".repeat(node.depth);
+        String label = indent + node.span.name();
+        if (label.length() > labelWidth) {
+            label = label.substring(0, labelWidth - 1) + "…";
+        } else {
+            label = String.format("%-" + labelWidth + "s", label);
+        }
+
+        // Calculate bar offset and width relative to trace timeline
+        long spanStart = node.span.startEpochNanos() - traceStart;
+        long spanDuration = node.span.endEpochNanos() - 
node.span.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.span.durationMs() + "ms";
+        int pad = Math.max(1, 8 - durationStr.length());
+
+        Style labelStyle = selected ? Style.EMPTY.fg(Color.CYAN).bold() : 
Style.EMPTY.fg(Color.CYAN);
+        Style bandStyle = node.span.isError()
+                ? Style.EMPTY.fg(Color.LIGHT_RED)
+                : TuiHelper.colorForDuration(node.span.durationMs(), 
minDuration, maxDuration);
+
+        return Line.from(
+                Span.styled(indicator, Style.EMPTY.fg(Color.YELLOW).bold()),
+                Span.styled(label, labelStyle),
+                Span.raw(gap),
+                Span.styled(bar, bandStyle),
+                Span.raw(" ".repeat(pad)),
+                Span.styled(durationStr, node.span.isError()
+                        ? Style.EMPTY.fg(Color.LIGHT_RED).bold()
+                        : Style.EMPTY.fg(Color.WHITE).bold()));
+    }
+
+    private void renderSpanDetail(Frame frame, Rect area, List<WaterfallNode> 
nodes) {
+        if (waterfallSelected < 0 || waterfallSelected >= nodes.size()) {
+            return;
+        }
+        SpanEntry span = nodes.get(waterfallSelected).span;
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(String.format("  Span: %s  Parent: %s  Kind: %s  Status: %s  
Duration: %dms",
+                span.spanId(), span.parentSpanId() != null ? 
span.parentSpanId() : "-",
+                span.kind(), span.status(), span.durationMs()));
+        if (span.attributes() != null && !span.attributes().isEmpty()) {
+            sb.append("  Attrs: ");
+            span.attributes().forEach((k, v) -> 
sb.append(k).append("=").append(v).append(" "));
+        }
+
+        Style titleStyle = span.isError() ? Style.EMPTY.fg(Color.LIGHT_RED) : 
Style.EMPTY.fg(Color.CYAN);
+        frame.renderWidget(
+                Paragraph.builder()
+                        .text(Text.from(Line.from(Span.styled(sb.toString(), 
Style.EMPTY.dim()))))
+                        .block(Block.builder().borderType(BorderType.ROUNDED)
+                                .title(dev.tamboui.widgets.block.Title.from(
+                                        Line.from(Span.styled(" " + 
span.name() + " ", titleStyle))))
+                                .build())
+                        .build(),
+                area);
+    }
+
+    @Override
+    public void renderFooter(List<Span> spans) {
+        if (waterfallView) {
+            spans.add(Span.styled(" ↑↓ ", 
Style.EMPTY.fg(Color.BLACK).onWhite()));
+            spans.add(Span.styled(" navigate ", Style.EMPTY));
+            spans.add(Span.styled(" Esc ", 
Style.EMPTY.fg(Color.BLACK).onWhite()));
+            spans.add(Span.styled(" back ", Style.EMPTY));
+            spans.add(Span.styled(" r ", 
Style.EMPTY.fg(Color.BLACK).onWhite()));
+            spans.add(Span.styled(" refresh ", Style.EMPTY));
+        } else {
+            spans.add(Span.styled(" ↑↓ ", 
Style.EMPTY.fg(Color.BLACK).onWhite()));
+            spans.add(Span.styled(" navigate ", Style.EMPTY));
+            spans.add(Span.styled(" Enter ", 
Style.EMPTY.fg(Color.BLACK).onWhite()));
+            spans.add(Span.styled(" waterfall ", Style.EMPTY));
+            spans.add(Span.styled(" r ", 
Style.EMPTY.fg(Color.BLACK).onWhite()));
+            spans.add(Span.styled(" refresh ", Style.EMPTY));
+        }
+    }
+
+    private List<TraceSummary> buildTraceSummaries() {
+        List<SpanEntry> currentSpans = spans.get();
+        Map<String, TraceSummary> byTrace = new LinkedHashMap<>();
+
+        for (SpanEntry span : currentSpans) {
+            TraceSummary ts = byTrace.computeIfAbsent(span.traceId(), k -> new 
TraceSummary(k));
+            ts.spanCount++;
+            if (span.isRoot()) {
+                ts.rootName = span.name();
+                ts.totalDurationMs = span.durationMs();
+            }
+            if (span.isError()) {
+                ts.hasError = true;
+            }
+            ts.maxDepth = Math.max(ts.maxDepth, 0);
+        }
+
+        List<TraceSummary> result = new ArrayList<>(byTrace.values());
+        // Fill duration from root span; if no root found, sum up
+        for (TraceSummary ts : result) {
+            if (ts.totalDurationMs == 0) {
+                ts.totalDurationMs = currentSpans.stream()
+                        .filter(s -> s.traceId().equals(ts.traceId))
+                        .mapToLong(SpanEntry::durationMs)
+                        .max().orElse(0);
+            }
+        }
+        // Newest first (highest startEpochNanos)
+        result.sort(Comparator.comparingLong(ts -> {
+            return -currentSpans.stream()
+                    .filter(s -> s.traceId().equals(ts.traceId))
+                    .mapToLong(SpanEntry::startEpochNanos)
+                    .max().orElse(0);
+        }));
+
+        return result;
+    }
+
+    private List<WaterfallNode> buildWaterfallNodes(String traceId) {
+        List<SpanEntry> traceSpans = spans.get().stream()
+                .filter(s -> traceId.equals(s.traceId()))
+                .sorted(Comparator.comparingLong(SpanEntry::startEpochNanos))
+                .toList();
+
+        if (traceSpans.isEmpty()) {
+            return List.of();
+        }
+
+        // Build parent-child tree
+        Map<String, List<SpanEntry>> childrenMap = new LinkedHashMap<>();
+        SpanEntry root = null;
+        for (SpanEntry span : traceSpans) {
+            if (span.isRoot()) {
+                root = span;
+            }
+            String parentId = span.parentSpanId();
+            if (parentId != null && !parentId.isEmpty()) {
+                childrenMap.computeIfAbsent(parentId, k -> new 
ArrayList<>()).add(span);
+            }
+        }
+
+        if (root == null && !traceSpans.isEmpty()) {
+            root = traceSpans.get(0);
+        }
+
+        List<WaterfallNode> result = new ArrayList<>();
+        addToWaterfall(result, root, childrenMap, 0);
+        return result;
+    }
+
+    private void addToWaterfall(
+            List<WaterfallNode> result, SpanEntry span,
+            Map<String, List<SpanEntry>> childrenMap, int depth) {
+        result.add(new WaterfallNode(span, depth));
+        List<SpanEntry> children = childrenMap.get(span.spanId());
+        if (children != null) {
+            for (SpanEntry child : children) {
+                addToWaterfall(result, child, childrenMap, depth + 1);
+            }
+        }
+    }
+
+    private static String shortId(String id) {
+        if (id == null || id.isEmpty()) {
+            return "";
+        }
+        return id.length() > 8 ? id.substring(0, 8) : id;
+    }
+
+    static class TraceSummary {
+        final String traceId;
+        String rootName;
+        int spanCount;
+        long totalDurationMs;
+        boolean hasError;
+        int maxDepth;
+
+        TraceSummary(String traceId) {
+            this.traceId = traceId;
+        }
+    }
+
+    record WaterfallNode(SpanEntry span, int depth) {
+    }
+}

Reply via email to