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) { } }
