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

davsclaus pushed a commit to branch CAMEL-23517-tui-history-tab
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 608487bffa01edfb8d05865fdc6931545cd76f54
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu May 14 14:46:19 2026 +0200

    CAMEL-23517: Add History tab to TUI monitor
    
    Add a new History tab (tab 7) that shows the last message history,
    similar to the CLI 'camel get history' command. Reads from the
    pid-history.json file which is overwritten each cycle.
    
    Features:
    - Direction arrows (-->/</--/>) showing message flow
    - Columns: direction, time, route, node ID, processor, elapsed, exchange
    - Detail panel with exchange info, headers, body, exception
    - h/b toggles for headers/body display
    - f toggle for follow mode
    - Auto-refreshes on each tick cycle
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 358 ++++++++++++++++++++-
 1 file changed, 351 insertions(+), 7 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 f033cb79884a..56de38e7283c 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
@@ -106,7 +106,7 @@ public class CamelMonitor extends CamelCommand {
     private static final int MAX_SPARKLINE_POINTS = 60;
     private static final int MAX_LOG_LINES = 200;
     private static final int MAX_TRACES = 200;
-    private static final int NUM_TABS = 6;
+    private static final int NUM_TABS = 7;
 
     // Tab indices
     private static final int TAB_OVERVIEW = 0;
@@ -114,7 +114,8 @@ public class CamelMonitor extends CamelCommand {
     private static final int TAB_HEALTH = 2;
     private static final int TAB_ENDPOINTS = 3;
     private static final int TAB_LOG = 4;
-    private static final int TAB_TRACE = 5;
+    private static final int TAB_HISTORY = 5;
+    private static final int TAB_TRACE = 6;
 
     // Route sort columns
     private static final String[] ROUTE_SORT_COLUMNS = { "mean", "max", 
"total", "failed", "name" };
@@ -171,6 +172,13 @@ public class CamelMonitor extends CamelCommand {
     private boolean showTraceBody = true;
     private boolean traceFollowMode = true;
 
+    // History state
+    private volatile List<HistoryEntry> historyEntries = 
Collections.emptyList();
+    private final TableState historyTableState = new TableState();
+    private boolean showHistoryHeaders = true;
+    private boolean showHistoryBody = true;
+    private boolean historyFollowMode = true;
+
     // Selected integration for detail views
     private String selectedPid;
 
@@ -280,6 +288,9 @@ public class CamelMonitor extends CamelCommand {
                 return handleTabKey(TAB_LOG);
             }
             if (ke.isChar('6')) {
+                return handleTabKey(TAB_HISTORY);
+            }
+            if (ke.isChar('7')) {
                 return handleTabKey(TAB_TRACE);
             }
 
@@ -479,6 +490,25 @@ public class CamelMonitor extends CamelCommand {
                     return true;
                 }
             }
+
+            // History tab: headers/body toggle and follow mode
+            if (tab == TAB_HISTORY) {
+                if (ke.isCharIgnoreCase('h')) {
+                    showHistoryHeaders = !showHistoryHeaders;
+                    return true;
+                }
+                if (ke.isCharIgnoreCase('b')) {
+                    showHistoryBody = !showHistoryBody;
+                    return true;
+                }
+                if (ke.isCharIgnoreCase('f')) {
+                    historyFollowMode = !historyFollowMode;
+                    if (historyFollowMode && !historyEntries.isEmpty()) {
+                        historyTableState.select(historyEntries.size() - 1);
+                    }
+                    return true;
+                }
+            }
         }
         if (event instanceof TickEvent) {
             long now = System.currentTimeMillis();
@@ -533,6 +563,10 @@ public class CamelMonitor extends CamelCommand {
                 traceFollowMode = false;
                 traceTableState.selectPrevious();
             }
+            case TAB_HISTORY -> {
+                historyFollowMode = false;
+                historyTableState.selectPrevious();
+            }
         }
     }
 
@@ -557,6 +591,7 @@ public class CamelMonitor extends CamelCommand {
                 List<TraceEntry> current = traces.get();
                 traceTableState.selectNext(current.size());
             }
+            case TAB_HISTORY -> 
historyTableState.selectNext(historyEntries.size());
         }
     }
 
@@ -611,7 +646,8 @@ public class CamelMonitor extends CamelCommand {
                         " 3 Health" + sel + " ",
                         " 4 Endpoints" + sel + " ",
                         " 5 Log" + sel + " ",
-                        " 6 Trace" + sel + " ")
+                        " 6 History" + sel + " ",
+                        " 7 Trace" + sel + " ")
                 .highlightStyle(Style.EMPTY.fg(Color.rgb(0xF6, 0x91, 
0x23)).bold())
                 .divider(Span.styled(" | ", Style.EMPTY.dim()))
                 .build();
@@ -627,6 +663,7 @@ public class CamelMonitor extends CamelCommand {
             case TAB_ENDPOINTS -> renderEndpoints(frame, area);
             case TAB_LOG -> renderLog(frame, area);
             case TAB_TRACE -> renderTrace(frame, area);
+            case TAB_HISTORY -> renderHistory(frame, area);
         }
     }
 
@@ -2050,6 +2087,163 @@ public class CamelMonitor extends CamelCommand {
         frame.renderWidget(detail, area);
     }
 
+    // ---- Tab 7: History ----
+
+    private void renderHistory(Frame frame, Rect area) {
+        IntegrationInfo info = findSelectedIntegration();
+        if (info == null) {
+            renderNoSelection(frame, area);
+            return;
+        }
+
+        List<HistoryEntry> current = historyEntries;
+
+        // Layout: history list (50%) + detail panel (50%)
+        List<Rect> chunks = Layout.vertical()
+                .constraints(Constraint.percentage(50), Constraint.fill())
+                .split(area);
+
+        // Auto-follow: select last entry
+        if (historyFollowMode && !current.isEmpty()) {
+            historyTableState.select(current.size() - 1);
+        }
+
+        // History list
+        List<Row> rows = new ArrayList<>();
+        for (HistoryEntry entry : current) {
+            Style dirStyle = entry.first ? Style.EMPTY.fg(Color.GREEN) : 
Style.EMPTY;
+            String elapsed = entry.elapsed >= 0 ? entry.elapsed + "ms" : "";
+
+            rows.add(Row.from(
+                    Cell.from(Span.styled(entry.direction, dirStyle)),
+                    Cell.from(entry.timestamp != null ? 
truncate(entry.timestamp, 12) : ""),
+                    Cell.from(Span.styled(
+                            entry.routeId != null ? truncate(entry.routeId, 
15) : "",
+                            Style.EMPTY.fg(Color.CYAN))),
+                    Cell.from(entry.nodeId != null ? truncate(entry.nodeId, 
15) : ""),
+                    Cell.from(entry.processor != null ? 
truncate(entry.processor, 25) : ""),
+                    Cell.from(elapsed),
+                    Cell.from(entry.exchangeId != null ? 
truncate(entry.exchangeId, 8) : "")));
+        }
+
+        Row header = Row.from(
+                Cell.from(Span.styled("", Style.EMPTY.bold())),
+                Cell.from(Span.styled("TIME", Style.EMPTY.bold())),
+                Cell.from(Span.styled("ROUTE", Style.EMPTY.bold())),
+                Cell.from(Span.styled("ID", Style.EMPTY.bold())),
+                Cell.from(Span.styled("PROCESSOR", Style.EMPTY.bold())),
+                Cell.from(Span.styled("ELAPSED", Style.EMPTY.bold())),
+                Cell.from(Span.styled("EXCHANGE", Style.EMPTY.bold())));
+
+        String historyTitle = String.format(" History [%d] %s ",
+                current.size(),
+                historyFollowMode ? "[FOLLOW]" : "[SCROLL]");
+
+        Table table = Table.builder()
+                .rows(rows)
+                .header(header)
+                .widths(
+                        Constraint.length(4),
+                        Constraint.length(12),
+                        Constraint.length(15),
+                        Constraint.length(15),
+                        Constraint.fill(),
+                        Constraint.length(10),
+                        Constraint.length(10))
+                .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+                .highlightSpacing(Table.HighlightSpacing.ALWAYS)
+                
.block(Block.builder().borderType(BorderType.ROUNDED).title(historyTitle).build())
+                .build();
+
+        frame.renderStatefulWidget(table, chunks.get(0), historyTableState);
+
+        // Detail panel
+        renderHistoryDetail(frame, chunks.get(1), current);
+    }
+
+    private void renderHistoryDetail(Frame frame, Rect area, 
List<HistoryEntry> current) {
+        Integer sel = historyTableState.selected();
+
+        if (sel == null || sel < 0 || sel >= current.size()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(
+                                    Span.styled(" Select a history entry to 
view details",
+                                            Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED)
+                                    .title(" Detail ").build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        HistoryEntry entry = current.get(sel);
+        List<Line> lines = new ArrayList<>();
+
+        // Exchange info
+        lines.add(Line.from(
+                Span.styled(" Exchange: ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                Span.raw(entry.exchangeId != null ? entry.exchangeId : "")));
+        lines.add(Line.from(
+                Span.styled(" Route:    ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                Span.raw(entry.routeId != null ? entry.routeId : ""),
+                Span.raw("  Node: "),
+                Span.raw(entry.nodeId != null ? entry.nodeId : ""),
+                Span.raw(entry.nodeLabel != null ? " (" + entry.nodeLabel + 
")" : "")));
+        lines.add(Line.from(
+                Span.styled(" Location: ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                Span.raw(entry.location != null ? entry.location : "")));
+        lines.add(Line.from(
+                Span.styled(" Elapsed:  ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                Span.raw(entry.elapsed >= 0 ? entry.elapsed + "ms" : ""),
+                Span.raw("  Thread: "),
+                Span.raw(entry.threadName != null ? entry.threadName : "")));
+        if (entry.failed) {
+            lines.add(Line.from(
+                    Span.styled(" Status:   ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                    Span.styled("Failed", Style.EMPTY.fg(Color.RED).bold())));
+        }
+        lines.add(Line.from(Span.raw("")));
+
+        // Headers
+        if (showHistoryHeaders && entry.headers != null && 
!entry.headers.isEmpty()) {
+            lines.add(Line.from(Span.styled(" Headers:", 
Style.EMPTY.fg(Color.GREEN).bold())));
+            for (Map.Entry<String, Object> h : entry.headers.entrySet()) {
+                lines.add(Line.from(
+                        Span.styled("   " + h.getKey(), 
Style.EMPTY.fg(Color.CYAN)),
+                        Span.raw(" = "),
+                        Span.raw(h.getValue() != null ? 
h.getValue().toString() : "null")));
+            }
+            lines.add(Line.from(Span.raw("")));
+        }
+
+        // Body
+        if (showHistoryBody && entry.body != null) {
+            lines.add(Line.from(Span.styled(" Body:", 
Style.EMPTY.fg(Color.GREEN).bold())));
+            String[] bodyLines = entry.body.split("\n");
+            for (String bl : bodyLines) {
+                lines.add(Line.from(Span.raw("   " + bl)));
+            }
+            lines.add(Line.from(Span.raw("")));
+        }
+
+        // Exception
+        if (entry.exception != null) {
+            lines.add(Line.from(Span.styled(" Exception:", 
Style.EMPTY.fg(Color.RED).bold())));
+            lines.add(Line.from(Span.raw("   " + entry.exception)));
+        }
+
+        String title = String.format(" Detail [%s] ", entry.exchangeId != null 
? truncate(entry.exchangeId, 30) : "");
+
+        Paragraph detail = Paragraph.builder()
+                .text(Text.from(lines))
+                .overflow(Overflow.CLIP)
+                
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                .build();
+
+        frame.renderWidget(detail, area);
+    }
+
     // ---- Shared rendering ----
 
     private void renderNoSelection(Frame frame, Rect area) {
@@ -2072,7 +2266,7 @@ public class CamelMonitor extends CamelCommand {
             hint(spans, "q", "quit");
             hint(spans, "\u2191\u2193", "navigate");
             hint(spans, "Enter", "details");
-            hint(spans, "1-6", "tabs");
+            hint(spans, "1-7", "tabs");
         } else if (tab == TAB_ROUTES && showDiagram) {
             String closeKey = diagramTextMode ? "D" : "d";
             hint(spans, closeKey + "/Esc", "close");
@@ -2091,12 +2285,12 @@ public class CamelMonitor extends CamelCommand {
             hint(spans, "s", "sort");
             hint(spans, "d", "diagram");
             hint(spans, "D", "text diagram");
-            hint(spans, "1-6", "tabs");
+            hint(spans, "1-7", "tabs");
         } else if (tab == TAB_HEALTH) {
             hint(spans, "Esc", "back");
             hint(spans, "\u2191\u2193", "navigate");
             hint(spans, "d", "toggle DOWN");
-            hint(spans, "1-6", "tabs");
+            hint(spans, "1-7", "tabs");
         } else if (tab == TAB_LOG) {
             hint(spans, "Esc", "back");
             hint(spans, "\u2191\u2193", "scroll");
@@ -2110,10 +2304,16 @@ public class CamelMonitor extends CamelCommand {
             hint(spans, "h", "headers" + (showTraceHeaders ? " [on]" : " 
[off]"));
             hint(spans, "b", "body" + (showTraceBody ? " [on]" : " [off]"));
             hintLast(spans, "f", "follow" + (traceFollowMode ? " [on]" : " 
[off]"));
+        } else if (tab == TAB_HISTORY) {
+            hint(spans, "Esc", "back");
+            hint(spans, "\u2191\u2193", "navigate");
+            hint(spans, "h", "headers" + (showHistoryHeaders ? " [on]" : " 
[off]"));
+            hint(spans, "b", "body" + (showHistoryBody ? " [on]" : " [off]"));
+            hintLast(spans, "f", "follow" + (historyFollowMode ? " [on]" : " 
[off]"));
         } else {
             hint(spans, "Esc", "back");
             hint(spans, "\u2191\u2193", "navigate");
-            hint(spans, "1-6", "tabs");
+            hint(spans, "1-7", "tabs");
         }
 
         frame.renderWidget(Paragraph.from(Line.from(spans)), area);
@@ -2213,6 +2413,9 @@ public class CamelMonitor extends CamelCommand {
 
             // Refresh trace data
             refreshTraceData(pids);
+
+            // Refresh history data
+            refreshHistoryData(pids);
         } catch (Exception e) {
             // ignore refresh errors
         }
@@ -2460,6 +2663,125 @@ public class CamelMonitor extends CamelCommand {
         return entry;
     }
 
+    @SuppressWarnings("unchecked")
+    private void refreshHistoryData(List<Long> pids) {
+        List<HistoryEntry> allEntries = new ArrayList<>();
+        for (Long pid : pids) {
+            Path historyFile = CommandLineHelper.getCamelDir().resolve(pid + 
"-history.json");
+            if (!Files.exists(historyFile)) {
+                continue;
+            }
+            try {
+                String content = Files.readString(historyFile);
+                if (content == null || content.isBlank()) {
+                    continue;
+                }
+                JsonObject json = (JsonObject) Jsoner.deserialize(content);
+                Object tracesArray = json.get("traces");
+                if (tracesArray instanceof List<?> traceList) {
+                    for (Object traceObj : traceList) {
+                        if (traceObj instanceof JsonObject traceJson) {
+                            HistoryEntry entry = parseHistoryEntry(traceJson, 
Long.toString(pid));
+                            if (entry != null) {
+                                allEntries.add(entry);
+                            }
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                // ignore
+            }
+        }
+        historyEntries = allEntries;
+    }
+
+    @SuppressWarnings("unchecked")
+    private HistoryEntry parseHistoryEntry(JsonObject json, String pid) {
+        HistoryEntry entry = new HistoryEntry();
+        entry.pid = pid;
+        entry.exchangeId = json.getString("exchangeId");
+        entry.routeId = json.getString("routeId");
+        entry.fromRouteId = json.getString("fromRouteId");
+        entry.nodeId = json.getString("nodeId");
+        entry.nodeShortName = json.getString("nodeShortName");
+        entry.nodeLabel = json.getString("nodeLabel");
+        entry.location = json.getString("location");
+        entry.threadName = json.getString("threadName");
+        entry.first = json.getBooleanOrDefault("first", false);
+        entry.last = json.getBooleanOrDefault("last", false);
+        entry.failed = json.getBooleanOrDefault("failed", false);
+
+        Object elapsedObj = json.get("elapsed");
+        if (elapsedObj instanceof Number n) {
+            entry.elapsed = n.longValue();
+        } else {
+            entry.elapsed = -1;
+        }
+
+        // Compute direction arrow
+        if (entry.first) {
+            entry.direction = "-->";
+        } else if (entry.last) {
+            entry.direction = "<--";
+        } else {
+            entry.direction = "  >";
+        }
+
+        // Compute processor label
+        if (entry.first) {
+            String uri = json.getString("endpointUri");
+            entry.processor = "from[" + (uri != null ? uri : "") + "]";
+        } else {
+            entry.processor = entry.nodeLabel;
+        }
+
+        // Timestamp
+        Object tsObj = json.get("timestamp");
+        if (tsObj instanceof Number n) {
+            long epochMs = n.longValue();
+            entry.timestamp = Instant.ofEpochMilli(epochMs)
+                    .atZone(ZoneId.systemDefault())
+                    .toLocalTime().toString();
+            if (entry.timestamp.length() > 12) {
+                entry.timestamp = entry.timestamp.substring(0, 12);
+            }
+        }
+
+        // Parse message
+        Object msgObj = json.get("message");
+        if (msgObj instanceof JsonObject message) {
+            Object headersObj = message.get("headers");
+            if (headersObj instanceof List<?> headerList) {
+                entry.headers = new LinkedHashMap<>();
+                for (Object h : headerList) {
+                    if (h instanceof JsonObject hObj) {
+                        entry.headers.put(
+                                String.valueOf(hObj.get("key")),
+                                hObj.get("value"));
+                    }
+                }
+            } else if (headersObj instanceof Map) {
+                entry.headers = new LinkedHashMap<>((Map<String, Object>) 
headersObj);
+            }
+
+            Object bodyObj = message.get("body");
+            if (bodyObj instanceof JsonObject bodyJson) {
+                Object val = bodyJson.get("value");
+                entry.body = val != null ? val.toString() : 
bodyJson.toString();
+            } else if (bodyObj != null) {
+                entry.body = bodyObj.toString();
+            }
+        }
+
+        // Exception
+        Object excObj = json.get("exception");
+        if (excObj instanceof JsonObject excJson) {
+            entry.exception = excJson.getString("message");
+        }
+
+        return entry;
+    }
+
     private static String stringValue(Object obj) {
         return obj != null ? obj.toString() : null;
     }
@@ -2818,6 +3140,28 @@ public class CamelMonitor extends CamelCommand {
         String message = "";
     }
 
+    static class HistoryEntry {
+        String pid;
+        String exchangeId;
+        String timestamp;
+        String routeId;
+        String fromRouteId;
+        String nodeId;
+        String nodeShortName;
+        String nodeLabel;
+        String location;
+        String processor;
+        String direction;
+        String threadName;
+        boolean first;
+        boolean last;
+        boolean failed;
+        long elapsed;
+        String body;
+        String exception;
+        Map<String, Object> headers;
+    }
+
     record VanishingInfo(IntegrationInfo info, long startTime) {
     }
 }

Reply via email to