This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch chore-tui-walk-fixes in repository https://gitbox.apache.org/repos/asf/camel.git
commit feaf3bb7f36c9c74c1661ed182f602ce121a90a9 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 9 15:42:24 2026 +0100 chore: camel-jbang - an example run from the TUI runs its source as saved, the log opens on a whole line, the circuit breaker chart shows its calls, events of one millisecond read newest first, and error counts add up - Source: the run state compared the save time of a file with the start of the JVM. An example run from the TUI writes its files from the integration itself, after its JVM started, so its source always said "not running yet: restart it". It now compares with the start of the CamelContext (startTimestamp). - Log: opening a log read its last 8 KB from inside a line, which showed cut and not compacted. The partial line is skipped (and comes back whole with the older lines), and the tab reads 32 KB, enough to fill a tall screen. - Circuit Breaker: the ok/fail msg/s chart was fed from the sliding window of the breaker, which stays the same while calls go on, and in whole calls a second, so it stayed at 0. It now charts the calls the protected steps answered and those the fallback answered, from running totals, scaled like the endpoint rates. A breaker that shows up once the tab is open is selected. The help said SUCCESS and FAIL were totals; they are the sliding window. - Events: events of the same millisecond came oldest first under the newest-first sort. - Errors: COUNT showed the count up to each kept entry (1, 2, 3, then 8, 9, 10), now only on the newest entry of each kind, so the column adds up; the title says how many errors happened when more than are kept. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../jbang/core/commands/tui/CircuitBreakerTab.java | 10 ++- .../dsl/jbang/core/commands/tui/ErrorsTab.java | 48 +++++++++++-- .../dsl/jbang/core/commands/tui/EventTab.java | 24 +++++-- .../jbang/core/commands/tui/IntegrationInfo.java | 2 + .../camel/dsl/jbang/core/commands/tui/LogTab.java | 43 ++++++++++-- .../jbang/core/commands/tui/MetricsCollector.java | 21 ++++-- .../jbang/core/commands/tui/SourceRunState.java | 5 +- .../dsl/jbang/core/commands/tui/StatusParser.java | 1 + .../src/main/resources/tui/help/circuit-breaker.md | 23 ++++--- .../src/main/resources/tui/help/errors.md | 12 ++-- .../commands/tui/CircuitBreakerTabRenderTest.java | 21 ++++++ .../core/commands/tui/ErrorsTabRenderTest.java | 23 +++++++ .../jbang/core/commands/tui/EventTabOrderTest.java | 56 +++++++++++++++ .../jbang/core/commands/tui/LogTabTailTest.java | 80 ++++++++++++++++++++++ .../tui/MetricsCollectorCircuitBreakerTest.java | 55 +++++++++++++++ .../core/commands/tui/SourceRunStateTest.java | 13 ++++ 16 files changed, 400 insertions(+), 37 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java index 544d980ae293..4550c9580a34 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java @@ -114,6 +114,10 @@ class CircuitBreakerTab extends AbstractTableTab { rows.add(emptyRow("No circuit breakers", 13)); } + // a breaker that shows up once the tab is open (the route is still starting) is selected like one that was there + if (!sorted.isEmpty() && tableState.selected() == null) { + tableState.select(0); + } CircuitBreakerInfo selectedCb = null; Integer sel = tableState.selected(); if (sel != null && sel >= 0 && sel < sorted.size()) { @@ -335,15 +339,17 @@ class CircuitBreakerTab extends AbstractTableTab { long curFail = failArr[renderPoints - 1]; Line chartTitle = Line.from( Span.styled("▬", Theme.success()), - Span.raw(String.format(" ok:%-4d ", curSuccess)), + Span.raw(String.format(" ok:%-4s ", MetricsCollector.formatThroughput(curSuccess))), Span.styled("▬", Theme.error()), - Span.raw(String.format(" fail:%-4d msg/s ", curFail))); + Span.raw(String.format(" fallback:%-4s msg/s ", MetricsCollector.formatThroughput(curFail)))); frame.renderWidget(DualSparkline.builder() .topData(successArr) .bottomData(failArr) .topStyle(Theme.success()) .bottomStyle(Theme.error()) .showYAxis(true) + // the rates are scaled by THROUGHPUT_SCALE; the axis shows msg/s like the title + .yAxisFormatter(MetricsCollector::formatThroughput) .xLabels("-" + renderPoints + "s", "-" + (renderPoints * 3 / 4) + "s", "-" + (renderPoints / 2) + "s", "-" + (renderPoints / 4) + "s", "now") .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).borderStyle(ctx.paneBorder(false)) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java index 48aeaa8492bf..4a6e16d1b2c3 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java @@ -18,8 +18,12 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import dev.tamboui.layout.Constraint; import dev.tamboui.layout.Layout; @@ -349,6 +353,7 @@ class ErrorsTab extends AbstractTableTab { } List<ErrorInfo> sorted = applyFilter(info.errors); + Set<ErrorInfo> newest = newestOfEachKind(sorted); List<Row> rows = new ArrayList<>(); for (ErrorInfo ei : sorted) { @@ -366,9 +371,7 @@ class ErrorsTab extends AbstractTableTab { Cell.from(Span.styled(ei.routeId != null ? ei.routeId : "", Style.EMPTY.fg(Theme.accent()))), Cell.from(ei.nodeId != null ? ei.nodeId : ""), Cell.from(Span.styled(handledStr, handledStyle)), - Cell.from(ei.repeatCount > 1 - ? Span.styled(Long.toString(ei.repeatCount), Theme.error().bold()) - : Span.raw(Long.toString(Math.max(1, ei.repeatCount)))), + countCell(ei, newest), ChaosMarks.markedCell(ChaosMarks.ruleOf(ctx, info, ei.exchangeId, ei.properties), shortException), Cell.from(ei.exceptionMessage != null ? ei.exceptionMessage : ""))); } @@ -412,7 +415,7 @@ class ErrorsTab extends AbstractTableTab { .highlightStyle(Theme.selectionBg()) .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).borderStyle(ctx.paneBorder(true)) - .title(" Errors [" + sorted.size() + "] ").build()) + .title(title(sorted.size(), info.errorCount)).build()) .build(); lastTableArea = chunks.get(0); @@ -571,6 +574,43 @@ class ErrorsTab extends AbstractTableTab { RuntimeFailures.lineText(file, line)); } + /** + * The title: how many errors are listed, and how many happened when that is more. The registry keeps the newest few + * of each kind of error (CAMEL-24911). + */ + static String title(int listed, int happened) { + if (happened > listed) { + return " Errors [" + listed + "] " + happened + " happened, the newest of each kind are kept "; + } + return " Errors [" + listed + "] "; + } + + /** + * How often a kind of error happened, on its newest entry: an older entry counts the times up to then, so it is + * left blank and the column adds up to the errors that happened. + */ + private static Cell countCell(ErrorInfo ei, Set<ErrorInfo> newest) { + if (!newest.contains(ei)) { + return Cell.from(""); + } + return ei.repeatCount > 1 + ? Cell.from(Span.styled(Long.toString(ei.repeatCount), Theme.error().bold())) + : Cell.from(Long.toString(Math.max(1, ei.repeatCount))); + } + + /** The newest entry of each kind of error (a route and an exception type): the one with the highest count. */ + static Set<ErrorInfo> newestOfEachKind(List<ErrorInfo> errors) { + Map<String, ErrorInfo> newest = new HashMap<>(); + for (ErrorInfo ei : errors) { + newest.merge(ei.routeId + "|" + ei.exceptionType, ei, + (a, b) -> b.repeatCount > a.repeatCount + || b.repeatCount == a.repeatCount && b.timestamp > a.timestamp ? b : a); + } + Set<ErrorInfo> set = Collections.newSetFromMap(new IdentityHashMap<>()); + set.addAll(newest.values()); + return set; + } + /** How often this kind of error happened, and how long it has been going on (CAMEL-24911). */ private static void addRepeatLine(List<Line> lines, ErrorInfo ei) { if (ei.repeatCount <= 1) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java index 04b0b0686beb..91fa539b245d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java @@ -17,6 +17,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import dev.tamboui.layout.Constraint; @@ -77,8 +78,7 @@ class EventTab extends AbstractTableTab { @Override protected void renderContent(Frame frame, Rect area, IntegrationInfo info) { - List<EventInfo> sorted = new ArrayList<>(info.events); - sorted.sort(this::sortEvent); + List<EventInfo> sorted = sortedEvents(info); List<Row> rows = new ArrayList<>(); for (EventInfo ei : sorted) { @@ -117,6 +117,20 @@ class EventTab extends AbstractTableTab { renderScrollbar(frame, table, sorted.size()); } + /** + * The events in the sort order. They come oldest first, and the sort keeps the order of equal ones: unless the sort + * is reversed, the list is turned around first, so events of the same millisecond (the created, sent and completed + * events of a quick exchange) read newest first like the others. + */ + List<EventInfo> sortedEvents(IntegrationInfo info) { + List<EventInfo> sorted = new ArrayList<>(info.events); + if (!sortReversed) { + Collections.reverse(sorted); + } + sorted.sort(this::sortEvent); + return sorted; + } + private int sortEvent(EventInfo a, EventInfo b) { int result = switch (sort) { case "category" -> { @@ -157,8 +171,7 @@ class EventTab extends AbstractTableTab { if (info == null || info.events.isEmpty()) { return null; } - List<EventInfo> sorted = new ArrayList<>(info.events); - sorted.sort(this::sortEvent); + List<EventInfo> sorted = sortedEvents(info); List<String> items = sorted.stream().map(e -> e.type != null ? e.type : "").toList(); Integer sel = tableState.selected(); return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Events"); @@ -183,8 +196,7 @@ class EventTab extends AbstractTableTab { JsonObject result = new JsonObject(); result.put("tab", "Events"); JsonArray rows = new JsonArray(); - List<EventInfo> sorted = new ArrayList<>(info.events); - sorted.sort(this::sortEvent); + List<EventInfo> sorted = sortedEvents(info); for (EventInfo ei : sorted) { JsonObject row = new JsonObject(); row.put("type", ei.type); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java index 9df5ad327f7b..9879aa7d6080 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java @@ -51,6 +51,8 @@ class IntegrationInfo { String ready; int state; long uptime; + // when the CamelContext started (epoch millis), after it loaded its routes; 0 when the integration does not say + long contextStarted; String ago; String throughput; long exchangesTotal; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java index f9d7e3b47a11..8442ece204b3 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java @@ -80,8 +80,13 @@ class LogTab extends AbstractTab { private volatile List<LogEntry> filteredLogEntries = new ArrayList<>(); private volatile boolean logLoading; private volatile boolean loadAllRequested; + /** How much of the end of a log the tab reads when it opens it. */ + static final int INITIAL_TAIL_BYTES = 32 * 1024; + private long logFilePos = -1; private long logFileStartPos = -1; + // the first read of a log starts inside a line: it is skipped up to its end + private boolean skipPartialLine; private long logTotalLinesRead; private String logFilePid; private final StringBuilder logLineBuffer = new StringBuilder(); @@ -717,7 +722,10 @@ class LogTab extends AbstractTab { } void readNewLogLinesFromFile(String pid, String fileName, List<String> newLines) { - Path logFile = CommandLineHelper.getCamelDir().resolve(fileName); + readNewLogLines(pid, CommandLineHelper.getCamelDir().resolve(fileName), newLines); + } + + void readNewLogLines(String pid, Path logFile, List<String> newLines) { if (!Files.exists(logFile)) { logFilePid = pid; logFilePos = -1; @@ -727,10 +735,11 @@ class LogTab extends AbstractTab { try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(), "r")) { long length = raf.length(); if (logFilePos < 0 || logFilePos > length) { - // Initial load: only read last 8KB for fast first render - logFilePos = Math.max(0, length - 8 * 1024); + // Initial load: only read the end of the file for a fast first render (enough to fill a tall screen) + logFilePos = Math.max(0, length - INITIAL_TAIL_BYTES); logFileStartPos = logFilePos; logLineBuffer.setLength(0); + skipPartialLine = logFilePos > 0; } if (logFilePos >= length) { return; @@ -739,9 +748,21 @@ class LogTab extends AbstractTab { // Cap per-tick read to 64KB to avoid blocking the refresh cycle byte[] buf = new byte[(int) Math.min(length - logFilePos, 64 * 1024)]; raf.readFully(buf); + int from = 0; + if (skipPartialLine) { + from = indexOfNewline(buf) + 1; + if (from == 0) { + // still inside the line the read started in + logFilePos += buf.length; + return; + } + skipPartialLine = false; + // the older lines are read up to the start of the first whole line + logFileStartPos = logFilePos + from; + } logFilePos += buf.length; - String chunk = logLineBuffer + new String(buf, StandardCharsets.UTF_8); + String chunk = logLineBuffer + new String(buf, from, buf.length - from, StandardCharsets.UTF_8); logLineBuffer.setLength(0); int start = 0; int end; @@ -760,11 +781,23 @@ class LogTab extends AbstractTab { } } + private static int indexOfNewline(byte[] buf) { + for (int i = 0; i < buf.length; i++) { + if (buf[i] == '\n') { + return i; + } + } + return -1; + } + void readOlderLogLines(String fileName, boolean loadAll, List<String> olderLines) { + readOlderLogLines(CommandLineHelper.getCamelDir().resolve(fileName), loadAll, olderLines); + } + + void readOlderLogLines(Path logFile, boolean loadAll, List<String> olderLines) { if (logFilePid == null || logFileStartPos <= 0) { return; } - Path logFile = CommandLineHelper.getCamelDir().resolve(fileName); if (!Files.exists(logFile)) { return; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java index 18bbcd30847e..6f555bccd66c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java @@ -412,15 +412,26 @@ class MetricsCollector { continue; } String key = info.pid + "/" + cb.id; - long success = cb.successfulCalls; - long failed = cb.failedCalls; - // Circuit breaker history stays unscaled (scale=1) because CircuitBreakerTab - // formats values as plain integers, not via formatThroughput() + long[] calls = cbCalls(cb); + long success = calls[0]; + long failed = calls[1]; + // scaled by THROUGHPUT_SCALE like the endpoint rates: in whole calls a second, a breaker called once a + // second reads 0 when the samples are a little more than a second apart recordEndpointSample(key, now, success, failed, - cbThroughputSamples, previousCbTime, cbSuccessHistory, cbFailHistory, 1); + cbThroughputSamples, previousCbTime, cbSuccessHistory, cbFailHistory, THROUGHPUT_SCALE); } } + /** + * The calls of a circuit breaker so far, as running totals: answered by the protected steps, and by the fallback (a + * failed, timed out or rejected call). The success and failed calls the breaker reports are those of its sliding + * window, which stay the same while the calls go on. + */ + static long[] cbCalls(CircuitBreakerInfo cb) { + long fallback = cb.fallbackCalls; + return new long[] { Math.max(0, cb.total - fallback), fallback }; + } + void updateHeapHistory(IntegrationInfo info) { if (info.heapMemUsed > 0) { long now = MonotonicClock.millis(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java index e038144d3741..b338bb0e547d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java @@ -95,6 +95,9 @@ final class SourceRunState { } // an integration that has not reloaded loaded its routes when it started; after a reload, when each started boolean reloaded = info.reloaded > 0 || info.reloadError != null || info.uptime <= 0; + // the routes were loaded before the context started, but after the JVM did: an example run from the TUI + // writes its files from the integration itself, after its JVM started + long loaded = info.contextStarted > 0 ? info.contextStarted : info.uptime; int routes = 0; int started = 0; int stale = 0; @@ -112,7 +115,7 @@ final class SourceRunState { if (reloaded && uptime < 0) { // a stopped route after a reload: when it was loaded is not known, so it may be of another version unknown++; - } else if ((reloaded ? Math.max(info.uptime, now - uptime) : info.uptime) < fileModified) { + } else if ((reloaded ? Math.max(loaded, now - uptime) : loaded) < fileModified) { stale++; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java index 99076064e21b..84b106f76216 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java @@ -68,6 +68,7 @@ final class StatusParser { info.pid = Long.toString(ph.pid()); info.uptime = extractSince(ph); info.ago = TimeUtils.printSince(info.uptime); + info.contextStarted = context.getLongOrDefault("startTimestamp", 0L); info.state = context.getIntegerOrDefault("phase", 0); info.camelVersion = context.getString("version"); info.profile = context.getString("profile"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/circuit-breaker.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/circuit-breaker.md index 2f167fa7117e..797ec6646829 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/circuit-breaker.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/circuit-breaker.md @@ -44,8 +44,8 @@ CLOSED ──(failures exceed threshold)──> OPEN - **STATE** — Current breaker state: `CLOSED` (green, normal), `OPEN` (red, rejecting), or `HALF_OPEN` (yellow, testing) - **WINDOW** — Number of calls in the current sliding window used to calculate the failure rate - **INFLIGHT** — Calls currently in progress inside the circuit breaker -- **SUCCESS** — Total number of successful calls -- **FAIL** — Total number of failed calls (exceptions thrown by the protected code) +- **SUCCESS** — Successful calls in the sliding window +- **FAIL** — Failed calls in the sliding window (exceptions thrown by the protected code) - **RATE%** — Current failure rate percentage in the sliding window. When this exceeds the configured threshold, the circuit trips to OPEN - **REJECT** — Calls rejected because the circuit is OPEN. These calls never reach the downstream service — they fail fast with a fallback - **FALLBACK** — Calls answered by the `onFallback`, whatever the cause: a failed call, a timeout or a rejected call. This is how many callers got a degraded answer @@ -76,13 +76,15 @@ HALF_OPEN state. ## Reading the counters -- FAIL counts calls the protected code failed, timeouts included. - TIMEOUT tells how many of those were timeouts. +- SUCCESS and FAIL are the calls of the sliding window, which the + breaker keeps to work out RATE%: they stop growing once the window + is full. FAIL includes the timeouts; TIMEOUT tells how many of those + were timeouts. - REJECT counts calls the OPEN breaker refused. They are not in FAIL. -- FALLBACK counts every call the `onFallback` answered, so it is - normally FAIL plus REJECT plus the bulkhead rejections, as long as - the breaker has a fallback. Without a fallback it stays at zero and - the exceptions reach the route instead. +- FALLBACK is a running total: every call the `onFallback` answered, + whether it failed, timed out or was rejected, as long as the breaker + has a fallback. Without a fallback it stays at zero and the + exceptions reach the route instead. - Calls rejected by a full bulkhead are not in REJECT; they appear as `bulkhead` in the detail view. @@ -92,8 +94,9 @@ The bottom panel shows when a circuit breaker is selected: - **Failure rate gauge**: Visual bar from 0% to 100%, colored green (low risk), yellow (approaching threshold), or red (above threshold) -- **Sparkline chart**: Mirrored view showing successful calls (green, - upward) vs failed calls (red, downward) over time +- **Sparkline chart**: Mirrored view of the calls per second: those the + protected steps answered (green, upward) and those the fallback answered + (red, downward), whether the call failed, timed out or was rejected - **Metrics**: Detailed counts for total, fail, inflight, reject, fallback, timeout, bulkhead (calls rejected because the bulkhead was full) and timing statistics (mean/min/max processing time) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md index 48b84e796c02..aa22d1fdf428 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md @@ -6,7 +6,9 @@ exception, the exchange state at the time of failure, and the full path the message took through the route before it failed. Camel maintains an error registry that keeps the most recent errors -(up to a configurable limit). Older errors are automatically purged. +(up to a configurable limit), and only the newest few of each kind of +error, so a storm of one failure does not push the others out. When +more errors happened than are kept, the title says how many happened. ## Error List @@ -15,15 +17,17 @@ Camel maintains an error registry that keeps the most recent errors - **ROUTE** — Route where the error happened - **NODE** — The specific processor/node in the route where the exception was thrown (e.g., `to1`, `bean2`, `process3`). This tells you exactly which step failed - **HANDLED** — Whether the error was caught by Camel's error handling: `true` (handled) or `false` (unhandled) +- **COUNT** — How often this kind of error (the same exception in the same route) happened, on its newest entry. The older entries of the kind are left blank, so the column adds up to the errors that happened - **EXCEPTION** — Exception class name in short form (e.g., `IOException`, `HttpOperationFailedException`) - **MESSAGE** — The exception message text explaining what went wrong ## Example Screen ``` - ID AGO ROUTE NODE HANDLED EXCEPTION MESSAGE - ID-myhost-1234-5 5s timer-route to1 false IOException Connection refused - ID-myhost-1234-3 2m kafka-route bean2 true NPE null reference in processor + ID AGO ROUTE NODE HANDLED COUNT EXCEPTION MESSAGE + ID-myhost-1234-5 5s timer-route to1 false 4 IOException Connection refused + ID-myhost-1234-4 9s timer-route to1 false IOException Connection refused + ID-myhost-1234-3 2m kafka-route bean2 true 1 NPE null reference in processor ``` ## Understanding HANDLED diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTabRenderTest.java index 203d787acc2a..ee03b8881f26 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTabRenderTest.java @@ -82,6 +82,8 @@ class CircuitBreakerTabRenderTest { @Test void renderRouteIdInCyan() { addCircuitBreaker("my-route", "cb1", "resilience4j", "closed"); + // the first row is selected, and its highlight takes the colors: the row looked at is the second + addCircuitBreaker("a-first", "cb0", "resilience4j", "forced_closed"); CircuitBreakerTab tab = new CircuitBreakerTab(ctx, new MetricsCollector()); @@ -97,6 +99,8 @@ class CircuitBreakerTabRenderTest { @Test void renderClosedStateInGreen() { addCircuitBreaker("route1", "cb1", "resilience4j", "closed"); + // the first row is selected, and its highlight takes the colors: the row looked at is the second + addCircuitBreaker("a-first", "cb0", "resilience4j", "forced_closed"); CircuitBreakerTab tab = new CircuitBreakerTab(ctx, new MetricsCollector()); @@ -112,6 +116,8 @@ class CircuitBreakerTabRenderTest { @Test void renderOpenStateInRed() { addCircuitBreaker("route1", "cb1", "resilience4j", "open"); + // the first row is selected, and its highlight takes the colors: the row looked at is the second + addCircuitBreaker("a-first", "cb0", "resilience4j", "forced_closed"); CircuitBreakerTab tab = new CircuitBreakerTab(ctx, new MetricsCollector()); @@ -127,6 +133,8 @@ class CircuitBreakerTabRenderTest { @Test void renderHalfOpenStateInYellow() { addCircuitBreaker("route1", "cb1", "resilience4j", "half_open"); + // the first row is selected, and its highlight takes the colors: the row looked at is the second + addCircuitBreaker("a-first", "cb0", "resilience4j", "forced_closed"); CircuitBreakerTab tab = new CircuitBreakerTab(ctx, new MetricsCollector()); @@ -161,6 +169,19 @@ class CircuitBreakerTabRenderTest { "Should show placeholder when no circuit breakers exist"); } + @Test + void aBreakerThatShowsUpOnceTheTabIsOpenIsSelected() { + CircuitBreakerTab tab = new CircuitBreakerTab(ctx, new MetricsCollector()); + tab.onTabSelected(); + TuiTestHelper.renderToString(tab, 160, 40); + + // the route was still starting: the breaker shows up a refresh later + addCircuitBreaker("route1", "cb1", "resilience4j", "closed"); + String rendered = TuiTestHelper.renderToString(tab, 160, 40); + + assertTrue(rendered.contains("cb1 (resilience4j)"), "the detail of the breaker shows: " + rendered); + } + @Test void renderNoSelectionShowsPrompt() { ctx.selectedPid = null; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java index 32068c8da965..f102251cebe1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import dev.tamboui.buffer.Buffer; @@ -195,6 +196,28 @@ class ErrorsTabRenderTest { String rendered = renderToString(tab, 160, 30); assertTrue(rendered.contains("Errors [2]"), "Title should show error count"); + assertFalse(rendered.contains("happened"), "No more errors happened than are listed"); + } + + @Test + void theCountIsOnTheNewestEntryOfAKindAndTheTitleSaysHowManyHappened() { + // the registry keeps the newest 3 of each kind: 3 of 3 connection failures, and the 8th to 10th rejection + for (int i = 1; i <= 3; i++) { + addError("ID-00" + i, "supplier", "throwException1", "ConnectException", "supplier unreachable", true); + info.errors.get(info.errors.size() - 1).repeatCount = i; + } + for (int i = 8; i <= 10; i++) { + addError("ID-01" + i, "stock-check", "setBody1", "CallNotPermittedException", "OPEN", true); + info.errors.get(info.errors.size() - 1).repeatCount = i; + } + info.errorCount = 13; + + assertEquals(Set.of(info.errors.get(2), info.errors.get(5)), ErrorsTab.newestOfEachKind(info.errors)); + + String rendered = renderToString(new ErrorsTab(ctx), 160, 30); + assertTrue(rendered.contains("Errors [6] 13 happened, the newest of each kind are kept"), rendered); + assertFalse(rendered.contains(" 8 "), "the older entries of a kind leave COUNT blank"); + assertTrue(rendered.contains("10")); } @Test diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTabOrderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTabOrderTest.java new file mode 100644 index 000000000000..617ccf94b60b --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTabOrderTest.java @@ -0,0 +1,56 @@ +/* + * 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.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The events of a quick exchange share a millisecond: they read newest first like the others. + */ +class EventTabOrderTest { + + @Test + void eventsOfTheSameMillisecondReadNewestFirst() { + IntegrationInfo info = new IntegrationInfo(); + // as the integration reports them: oldest first + info.events.add(event("ExchangeCreated", 1000)); + info.events.add(event("ExchangeSending", 1000)); + info.events.add(event("ExchangeSent", 1000)); + info.events.add(event("ExchangeCompleted", 1000)); + info.events.add(event("ExchangeCreated", 2000)); + info.events.add(event("ExchangeCompleted", 2000)); + + EventTab tab = new EventTab(new MonitorContext(new AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()))); + + assertThat(tab.sortedEvents(info)).extracting(e -> e.type + "@" + e.timestamp).containsExactly( + "ExchangeCompleted@2000", "ExchangeCreated@2000", + "ExchangeCompleted@1000", "ExchangeSent@1000", "ExchangeSending@1000", "ExchangeCreated@1000"); + } + + private static EventInfo event(String type, long timestamp) { + EventInfo e = new EventInfo(); + e.type = type; + e.category = "exchange"; + e.timestamp = timestamp; + return e; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabTailTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabTailTest.java new file mode 100644 index 000000000000..bd030d623ae3 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabTailTest.java @@ -0,0 +1,80 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The Log tab opens a log at its end, which starts inside a line: that line is left out of the first read, and comes + * back whole with the older lines. + */ +class LogTabTailTest { + + @TempDir + Path dir; + + @Test + void theFirstReadStartsAtAWholeLine() throws Exception { + List<String> written = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; sb.length() < 3 * LogTab.INITIAL_TAIL_BYTES; i++) { + String line = "2026-11-09 15:13:22.234 INFO 292054 --- [timer://stock] route : Stock check " + i + + " é in stock at the supplier"; + written.add(line); + sb.append(line).append('\n'); + } + Path log = dir.resolve("1234.log"); + Files.writeString(log, sb, StandardCharsets.UTF_8); + + LogTab tab = new LogTab(new MonitorContext(new AtomicReference<>(List.of()), new AtomicReference<>(List.of()))); + List<String> lines = new ArrayList<>(); + tab.readNewLogLines("1234", log, lines); + + assertThat(lines).isNotEmpty(); + assertThat(written).containsSequence(lines); + assertThat(lines.get(lines.size() - 1)).isEqualTo(written.get(written.size() - 1)); + + // the older lines end with the line just before the first one read: none is lost or cut + List<String> older = new ArrayList<>(); + tab.readOlderLogLines(log, true, older); + List<String> all = new ArrayList<>(older); + all.addAll(lines); + assertThat(all).isEqualTo(written); + } + + @Test + void aShortLogIsReadWhole() throws Exception { + Path log = dir.resolve("1234.log"); + Files.writeString(log, "first\nsecond\n", StandardCharsets.UTF_8); + + LogTab tab = new LogTab(new MonitorContext(new AtomicReference<>(List.of()), new AtomicReference<>(List.of()))); + List<String> lines = new ArrayList<>(); + tab.readNewLogLines("1234", log, lines); + + assertThat(lines).containsExactly("first", "second"); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollectorCircuitBreakerTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollectorCircuitBreakerTest.java new file mode 100644 index 000000000000..2569f8584ab8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollectorCircuitBreakerTest.java @@ -0,0 +1,55 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The calls per second of the Circuit Breaker chart come from running totals, not from the sliding window of the + * breaker, which stays the same while the calls go on. + */ +class MetricsCollectorCircuitBreakerTest { + + @Test + void theCallsAreRunningTotalsNotTheSlidingWindow() { + CircuitBreakerInfo cb = new CircuitBreakerInfo(); + // a window of 4, full of successful calls, after 5947 calls of which the fallback answered 13 + cb.bufferedCalls = 4; + cb.successfulCalls = 4; + cb.total = 5947; + cb.fallbackCalls = 13; + + assertThat(MetricsCollector.cbCalls(cb)).containsExactly(5934, 13); + + // a second later the window is the same, the totals have moved on + cb.total = 5948; + assertThat(MetricsCollector.cbCalls(cb)).containsExactly(5935, 13); + } + + @Test + void anOutageIsAnsweredByTheFallback() { + CircuitBreakerInfo cb = new CircuitBreakerInfo(); + cb.total = 100; + cb.fallbackCalls = 10; + cb.total += 5; + cb.fallbackCalls += 5; + + assertThat(MetricsCollector.cbCalls(cb)).containsExactly(90, 15); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java index 983dd5d9ba97..88c82c85a389 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java @@ -66,6 +66,19 @@ class SourceRunStateTest { assertThat(text(SourceRunState.line(state))).isEqualTo(TuiIcons.SELECTED + " running as saved"); } + @Test + void writtenByTheIntegrationBeforeItsContextStarted() { + // an example run from the TUI writes its files from the integration itself: after the JVM started, before + // the routes were loaded and the context started + info.contextStarted = NOW - 58_000; + route("orders", "orders.camel.yaml:3", "57s"); + + assertThat(SourceRunState.of(info, FILE, NOW - 59_000, NOW).kind()).isEqualTo(SourceRunState.Kind.RUNNING); + // saved after the context started: not what runs + assertThat(SourceRunState.of(info, FILE, NOW - 10_000, NOW).kind()) + .isEqualTo(SourceRunState.Kind.NOT_RUNNING); + } + @Test void savedAfterTheIntegrationStartedWithoutDevMode() { route("orders", "orders.camel.yaml:3", "59s");
