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

davsclaus pushed a commit to branch chore-producers-about-strips-watch
in repository https://gitbox.apache.org/repos/asf/camel.git

commit ddf7f02ba52579d80cce9f5e70ecfae9a7a41894
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 16:06:12 2026 +0100

    chore: camel-jbang - Producers says what was sent, the strips of What 
happened fill the width, and Watch calms down once the errors stopped
    
    - Producers: TOTAL, FAIL and P50/P95/P99 of each producer, from the steps of
      its route that send to its endpoint (blank for a toD whose endpoint is
      computed per message).
    - About, What happened: the notes after a strip are measured instead of
      reserving 50 columns, and the strips may have up to 300 columns: a 200 
wide
      screen has 150 columns of 2 seconds instead of 100 of 3.
    - About, Watch: says when the last error happened, and once the errors 
stopped
      more than 5 minutes ago, that there were none in the last 5 minutes, with 
the
      earlier ones dimmed. camel-cli-connector writes when the newest error 
happened
      in the error summary of the status file (lastTimestamp).
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../apache/camel/cli/connector/ErrorCounts.java    |  12 ++-
 .../camel/cli/connector/ErrorCountsTest.java       |   4 +
 .../dsl/jbang/core/commands/tui/AboutView.java     |  61 +++++++++++--
 .../jbang/core/commands/tui/IntegrationInfo.java   |   2 +
 .../dsl/jbang/core/commands/tui/ProducersTab.java  |  82 ++++++++++++++++-
 .../dsl/jbang/core/commands/tui/StatusParser.java  |   1 +
 .../src/main/resources/tui/help/overview.md        |   6 +-
 .../src/main/resources/tui/help/producers.md       |   9 +-
 .../dsl/jbang/core/commands/tui/AboutViewTest.java |  53 ++++++++++-
 .../core/commands/tui/ProducersTabStatsTest.java   | 100 +++++++++++++++++++++
 10 files changed, 311 insertions(+), 19 deletions(-)

diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ErrorCounts.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ErrorCounts.java
index 4cdb621699cc..06e85d7bea9d 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ErrorCounts.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ErrorCounts.java
@@ -39,11 +39,13 @@ final class ErrorCounts {
     }
 
     /**
-     * Adds to the summary the errors that happened ({@code total}) and the 
kinds that happened most ({@code kinds}:
-     * routeId, exceptionType, count), from the entries of the errors dev 
console.
+     * Adds to the summary the errors that happened ({@code total}), the kinds 
that happened most ({@code kinds}:
+     * routeId, exceptionType, count), and when the newest happened ({@code 
lastTimestamp}, epoch millis, when there is
+     * one), from the entries of the errors dev console.
      */
     static void summarize(Map<String, Object> console, JsonObject summary) {
         Map<String, Kind> kinds = new LinkedHashMap<>();
+        long last = 0;
         if (console.get("errors") instanceof List<?> entries) {
             for (Object o : entries) {
                 if (o instanceof Map<?, ?> e) {
@@ -53,6 +55,9 @@ final class ErrorCounts {
                     String key = routeId + "|" + string(e.get("nodeId")) + "|" 
+ type;
                     Kind k = kinds.computeIfAbsent(key, x -> new Kind(routeId, 
type));
                     k.count = Math.max(k.count, count);
+                    if (e.get("timestamp") instanceof Number ts) {
+                        last = Math.max(last, ts.longValue());
+                    }
                 }
             }
         }
@@ -72,6 +77,9 @@ final class ErrorCounts {
         }
         summary.put("total", total);
         summary.put("kinds", top);
+        if (last > 0) {
+            summary.put("lastTimestamp", last);
+        }
     }
 
     private static String string(Object o) {
diff --git 
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/ErrorCountsTest.java
 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/ErrorCountsTest.java
index 2abebfb479cc..c3c5cf9b6368 100644
--- 
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/ErrorCountsTest.java
+++ 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/ErrorCountsTest.java
@@ -47,6 +47,7 @@ class ErrorCountsTest {
                 }
             });
             context.start();
+            long before = System.currentTimeMillis();
             for (int i = 0; i < 10; i++) {
                 send(context, "direct:supplier");
             }
@@ -71,6 +72,8 @@ class ErrorCountsTest {
             
assertThat(first.getString("exceptionType")).isEqualTo("java.net.ConnectException");
             assertThat(first.getLong("count")).isEqualTo(10);
             assertThat(((JsonObject) 
kinds.get(1)).getLong("count")).isEqualTo(2);
+            // when the newest error happened: the last of the 2 sent to the 
stock route
+            assertThat(summary.getLong("lastTimestamp")).isBetween(before, 
System.currentTimeMillis());
         }
     }
 
@@ -80,6 +83,7 @@ class ErrorCountsTest {
         ErrorCounts.summarize(Map.of("size", 0), summary);
         assertThat(summary.getLong("total")).isZero();
         assertThat((List<?>) summary.get("kinds")).isEmpty();
+        assertThat(summary.containsKey("lastTimestamp")).isFalse();
     }
 
     private static void send(CamelContext context, String uri) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
index 4aba7b02e35f..cfd9f733f460 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
@@ -36,6 +36,7 @@ import dev.tamboui.layout.Padding;
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Style;
 import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
 import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
@@ -67,9 +68,9 @@ final class AboutView {
     // the strips have at least this many columns, whatever the zoom, and as 
many as the width allows up to the most,
     // a column taking a whole number of seconds
     private static final int STRIP_COLUMNS = 60;
-    private static final int MAX_STRIP_COLUMNS = 150;
-    // what the line of a route has after its strip: its exchanges, an open 
circuit breaker, the worst that happened
-    private static final int STRIP_NOTES = 50;
+    private static final int MAX_STRIP_COLUMNS = 300;
+    // the cause of a recovery after the strip of a route, cut to this width 
when the strips need the room
+    private static final int CAUSE_WIDTH = 30;
     // the sparklines of In and Out, wider in a wider column
     private static final int SPARK_WIDTH = 14;
     private static final int MAX_SPARK_WIDTH = 30;
@@ -621,14 +622,23 @@ final class AboutView {
         List<String> order = routeOrder(info);
         // the names, then one space: every line starts its columns at the 
same place
         int label = Math.min(18, Math.max(8, 
order.stream().mapToInt(String::length).max().orElse(8))) + 4;
-        int columns = stripColumns(seconds, width - label - STRIP_NOTES);
+        List<Timeline.RouteSeries> series = ctx.timeline.routes(info.pid, 
order, end, seconds);
+        // what the line of a route has after its strip (its exchanges, an 
open circuit breaker, the worst that
+        // happened, with the cause of a recovery up to CAUSE_WIDTH) takes the 
room it needs, and the strips the rest
+        int notes = 0;
+        for (Timeline.RouteSeries r : series) {
+            notes = Math.max(notes, width(stripNotes(info, r, last, end, 
seconds, CAUSE_WIDTH)));
+        }
+        int columns = stripColumns(seconds, width - label - notes - 1);
+        // the cause gets what the strips leave too
+        int causeRoom = CAUSE_WIDTH + Math.max(0, width - label - notes - 1 - 
columns);
         stripColumns = columns;
         int per = seconds / columns;
         // the columns that end before the integration started are blank, as 
the sparklines have them, where a dot is
         // a time it ran and did nothing (uptime is the time it started)
         long startOffset = info.uptime > 0 ? info.uptime / 1000 - (end - 
seconds + 1) : 0;
         int before = (int) Math.max(0, Math.min(columns, startOffset / per));
-        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, order, 
end, seconds)) {
+        for (Timeline.RouteSeries r : series) {
             if (spaced && !lines.isEmpty()) {
                 lines.add(Line.from(Span.raw("")));
             }
@@ -658,7 +668,7 @@ final class AboutView {
                         ? "   " + name + " ".repeat(Math.max(1, label - 3 - 
name.length())) : " ".repeat(label)));
                 spans.addAll(rows.get(row));
                 if (row == tall - 1) {
-                    spans.addAll(stripNotes(info, r, last, end, seconds, width 
- label - columns - 40));
+                    spans.addAll(stripNotes(info, r, last, end, seconds, 
causeRoom));
                 }
                 lines.add(Line.from(spans));
             }
@@ -741,6 +751,14 @@ final class AboutView {
         return spans;
     }
 
+    private static int width(List<Span> spans) {
+        int w = 0;
+        for (Span span : spans) {
+            w += CharWidth.of(span.content());
+        }
+        return w;
+    }
+
     /**
      * The columns of the strips: the most that fit the room, up to {@value 
#MAX_STRIP_COLUMNS}, with a whole number of
      * seconds in each; at least {@value #STRIP_COLUMNS}.
@@ -1031,21 +1049,34 @@ final class AboutView {
         List<Span> watch = new ArrayList<>();
         watch.add(Span.styled(" Watch    ", Theme.muted()));
         if (info.errorCount > 0) {
-            watch.add(Span.styled("✗ " + info.errorCount + (info.errorCount == 
1 ? " error" : " errors"), Theme.error()));
+            // errors that stopped before the last minutes are history: the 
line says so, and calms down
+            long lastError = lastError(info);
+            boolean old = lastError > 0 && System.currentTimeMillis() - 
lastError > HAPPENED_SECONDS * 1000L;
+            String count = info.errorCount + (info.errorCount == 1 ? " error" 
: " errors");
+            Style style = old ? Theme.muted() : Theme.error();
+            if (old) {
+                watch.add(Span.styled("no errors in the last " + 
HAPPENED_SECONDS / 60 + "m", Theme.success()));
+                watch.add(Span.styled(" · " + count + " before", style));
+            } else {
+                watch.add(Span.styled("✗ " + count, style));
+            }
             // the kinds that happened most, when there are more than one
             List<IntegrationInfo.ErrorKind> kinds = info.errorKinds;
             if (kinds.size() > 1) {
                 for (int i = 0; i < Math.min(3, kinds.size()); i++) {
                     IntegrationInfo.ErrorKind k = kinds.get(i);
                     watch.add(Span.styled(i == 0 ? ": " : " · ", 
Theme.muted()));
-                    watch.add(Span.styled(simpleName(k.exceptionType()) + " ×" 
+ k.count(), Theme.error()));
+                    watch.add(Span.styled(simpleName(k.exceptionType()) + " ×" 
+ k.count(), style));
                 }
                 if (kinds.size() > 3) {
                     watch.add(Span.styled(" …", Theme.muted()));
                 }
             } else if (kinds.size() == 1) {
                 watch.add(Span.styled(": ", Theme.muted()));
-                
watch.add(Span.styled(simpleName(kinds.get(0).exceptionType()), Theme.error()));
+                
watch.add(Span.styled(simpleName(kinds.get(0).exceptionType()), style));
+            }
+            if (lastError > 0) {
+                watch.add(Span.styled(" · the last " + 
TimeUtils.printSince(lastError) + " ago", Theme.muted()));
             }
             // the key to the Errors tab, as the Next line has its keys (a 
bare 9 read as a second count of errors)
             watch.add(Span.raw("   "));
@@ -1068,6 +1099,18 @@ final class AboutView {
         return lines;
     }
 
+    /**
+     * When the newest error happened (epoch millis), 0 when not known: as the 
integration says it (Camel 4.24), else
+     * from the errors the Errors tab loaded.
+     */
+    private static long lastError(IntegrationInfo info) {
+        long last = info.lastErrorTimestamp;
+        for (ErrorInfo e : info.errors) {
+            last = Math.max(last, e.timestamp);
+        }
+        return last;
+    }
+
     private static String simpleName(String type) {
         if (type == null) {
             return "error";
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..79d4e85762b4 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
@@ -116,6 +116,8 @@ class IntegrationInfo {
     final List<KafkaConsumerInfo> kafkaConsumers = new ArrayList<>();
     // the errors that happened (the registry keeps only a few entries of each 
kind), and the kinds that happened most
     int errorCount;
+    // when the newest error happened (epoch millis), 0 when the integration 
does not say
+    long lastErrorTimestamp;
     volatile List<ErrorKind> errorKinds = List.of();
     volatile List<ErrorInfo> errors = new ArrayList<>();
     boolean inflightBrowseEnabled;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java
index 25ec16d59033..11a43c2c71de 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java
@@ -58,21 +58,29 @@ class ProducersTab extends AbstractTableTab {
         List<ProducerInfo> sorted = new ArrayList<>(info.producers);
         sorted.sort(this::sortProducer);
 
+        boolean percentiles = sorted.stream().map(pi -> stats(info, pi))
+                .anyMatch(st -> st != null && st.busiest().p50Time >= 0);
+
         List<Row> rows = new ArrayList<>();
         for (ProducerInfo pi : sorted) {
             String type = producerType(pi);
             Style statusStyle = "Started".equals(pi.state) ? Theme.success() : 
Theme.error();
+            Stats st = stats(info, pi);
 
             rows.add(Row.from(
                     Cell.from(Span.styled(" " + (pi.routeId != null ? 
pi.routeId : ""), Style.EMPTY.fg(Theme.accent()))),
                     Cell.from(Span.styled(pi.state != null ? pi.state : "", 
statusStyle)),
                     Cell.from(type),
                     Cell.from(pi.remote ? "x" : ""),
+                    rightCell(st != null ? String.valueOf(st.total()) : "", 
10),
+                    st != null && st.failed() > 0
+                            ? rightCell(String.valueOf(st.failed()), 8, 
Theme.error()) : rightCell("", 8),
+                    rightCell(st != null ? timing(st.busiest(), percentiles) : 
"", 16),
                     Cell.from(pi.uri != null ? pi.uri : "")));
         }
 
         if (rows.isEmpty()) {
-            rows.add(emptyRow("No producers", 5));
+            rows.add(emptyRow("No producers", 8));
         }
 
         Table table = Table.builder()
@@ -82,12 +90,18 @@ class ProducersTab extends AbstractTableTab {
                         Cell.from(Span.styled(sortLabel("STATUS", "status"), 
sortStyle("status"))),
                         Cell.from(Span.styled(sortLabel("TYPE", "type"), 
sortStyle("type"))),
                         Cell.from(Span.styled("REMOTE", Style.EMPTY.bold())),
+                        rightCell("TOTAL", 10, Style.EMPTY.bold()),
+                        rightCell("FAIL", 8, Style.EMPTY.bold()),
+                        rightCell(percentiles ? "P50/P95/P99" : 
"MIN/MAX/MEAN", 16, Style.EMPTY.bold()),
                         Cell.from(Span.styled(sortLabel("URI", "uri"), 
sortStyle("uri")))))
                 .widths(
                         Constraint.length(20),
                         Constraint.length(10),
                         Constraint.length(20),
                         Constraint.length(8),
+                        Constraint.length(10),
+                        Constraint.length(8),
+                        Constraint.length(16),
                         Constraint.fill())
                 .highlightStyle(Theme.selectionBg())
                 .highlightSpacing(Table.HighlightSpacing.ALWAYS)
@@ -126,6 +140,59 @@ class ProducersTab extends AbstractTableTab {
         return sortReversed ? -result : result;
     }
 
+    /**
+     * What a producer sent: the statistics of the steps of its route that 
send to its endpoint (to, wireTap, enrich),
+     * and the busiest of them for the timings. Null when no step does, such 
as a toD whose endpoint is computed.
+     */
+    record Stats(long total, long failed, ProcessorInfo busiest) {
+    }
+
+    static Stats stats(IntegrationInfo info, ProducerInfo pi) {
+        if (pi.routeId == null || pi.uri == null) {
+            return null;
+        }
+        String uri = normalize(pi.uri);
+        long total = 0;
+        long failed = 0;
+        ProcessorInfo busiest = null;
+        for (RouteInfo route : info.routes) {
+            if (!pi.routeId.equals(route.routeId)) {
+                continue;
+            }
+            for (ProcessorInfo step : route.processors) {
+                if (step.uri != null && uri.equals(normalize(step.uri))) {
+                    total += step.total;
+                    failed += step.failed;
+                    if (busiest == null || step.total > busiest.total) {
+                        busiest = step;
+                    }
+                }
+            }
+        }
+        return busiest != null ? new Stats(total, failed, busiest) : null;
+    }
+
+    /** The same endpoint with or without the // after the scheme (direct:a 
and direct://a). */
+    private static String normalize(String uri) {
+        int colon = uri.indexOf(':');
+        if (colon > 0 && !uri.startsWith("//", colon + 1)) {
+            return uri.substring(0, colon) + "://" + uri.substring(colon + 1);
+        }
+        return uri;
+    }
+
+    private static String timing(ProcessorInfo step, boolean percentiles) {
+        if (percentiles && step.p50Time >= 0) {
+            return formatDurationMs(step.p50Time) + "/" + 
formatDurationMs(step.p95Time) + "/"
+                   + formatDurationMs(step.p99Time);
+        }
+        if (step.total > 0) {
+            return formatDurationMs(step.minTime) + "/" + 
formatDurationMs(step.maxTime) + "/"
+                   + formatDurationMs(step.meanTime);
+        }
+        return "";
+    }
+
     private static String producerType(ProducerInfo pi) {
         if (pi.className == null) {
             return "";
@@ -183,6 +250,19 @@ class ProducersTab extends AbstractTableTab {
             if (pi.stepId != null) {
                 row.put("stepId", pi.stepId);
             }
+            Stats st = stats(info, pi);
+            if (st != null) {
+                row.put("total", st.total());
+                row.put("failed", st.failed());
+                ProcessorInfo b = st.busiest();
+                if (b.p50Time >= 0) {
+                    row.put("p50Time", b.p50Time);
+                    row.put("p95Time", b.p95Time);
+                    row.put("p99Time", b.p99Time);
+                } else {
+                    row.put("meanTime", b.meanTime);
+                }
+            }
             rows.add(row);
         }
         result.put("rows", rows);
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..c2f017e964fc 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
@@ -128,6 +128,7 @@ final class StatusParser {
                 }
             }
             info.errorKinds = List.copyOf(kinds);
+            info.lastErrorTimestamp = 
errorsObj.getLongOrDefault("lastTimestamp", 0L);
         }
 
         parseInflight(info, root);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
index 8dcd6594f3c9..40759767756d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
@@ -28,8 +28,10 @@ happened to it, with no AI:
 - **Last 5m** — the exchanges and how they ended, the cause that
   happened most and when (a chaos rule marked 🎲), and the exchanges
   since it started.
-- **Watch** — the errors that happened and the kinds that happened most,
-  routes not started, exchanges in flight.
+- **Watch** — the errors that happened, the kinds that happened most and
+  when the last one did, routes not started, exchanges in flight. Errors
+  that stopped more than 5 minutes ago are history: the line says there
+  were none in the last 5 minutes, with the earlier ones dimmed.
 - **Next** — the keys that lead deeper: **4** the Timeline, **5** the
   Diagram, **2** the source.
 - **What happened** — a strip per route over the last 5 minutes (as wide
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/producers.md
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/producers.md
index 17c0d8086f69..f709f0ed640f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/producers.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/producers.md
@@ -11,9 +11,16 @@ Unlike consumers (one per route), a route can have multiple 
producers
 - **ROUTE** — The route this producer belongs to
 - **STATUS** — Producer state: `Started` (running normally) or `Stopped`
 - **TYPE** — The Camel component type (e.g., `Kafka`, `Http`, `Log`, `Seda`)
-- **REMOTE** — Whether this producer sends to a remote system (`Yes`) or is 
in-process (`No`)
+- **REMOTE** — `x` when this producer sends to a remote system, blank when it 
is in-process
+- **TOTAL** — The messages sent: the exchanges of the steps of the route that 
send to this endpoint (`to`, `wireTap`, `enrich`)
+- **FAIL** — How many of those sends failed
+- **P50/P95/P99** — How long a send took, from the busiest of those steps 
(`MIN/MAX/MEAN` when the integration does not record percentiles)
 - **URI** — The full endpoint URI (e.g., `kafka://my-topic`, `log://mylogger`)
 
+The numbers are blank for a producer no step of its route sends to by
+that URI, such as the one of a `toD` whose endpoint is computed per
+message.
+
 ## Keys
 
 - `Up/Down` — select producer
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
index a2b942acd9f6..87213d277a56 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
@@ -112,8 +112,8 @@ class AboutViewTest {
         // the outage was 4.5 minutes ago, for 15 seconds
         ctx.timeline.merge("1234", TimelineTabRenderTest.outage(end, 300, 
0).json(), true);
         AboutView about = new AboutView(ctx);
-        // 160 wide has room for 75 columns of 4 seconds
-        assertThat(render(about, info)).contains("What happened ─ last 5m, a 
column is 4s ─ + zoom in, 4 for each");
+        // 160 wide has room for 100 columns of 3 seconds next to the notes of 
the route
+        assertThat(render(about, info)).contains("What happened ─ last 5m, a 
column is 3s ─ + zoom in, 4 for each");
 
         assertThat(about.handleKeyEvent(KeyEvent.ofChar('+'), info)).isTrue();
         // 2 minutes from the first second recorded, with the outage in it
@@ -132,15 +132,60 @@ class AboutViewTest {
 
     @Test
     void theStripsAreAsWideAsTheRoomAllows() {
-        // a whole number of seconds in a column, at least 60 columns and at 
most 150
+        // a whole number of seconds in a column, at least 60 columns and at 
most 300
         assertThat(AboutView.stripColumns(300, 40)).isEqualTo(60);
         assertThat(AboutView.stripColumns(300, 94)).isEqualTo(75);
         assertThat(AboutView.stripColumns(300, 129)).isEqualTo(100);
-        assertThat(AboutView.stripColumns(300, 500)).isEqualTo(150);
+        assertThat(AboutView.stripColumns(300, 160)).isEqualTo(150);
+        assertThat(AboutView.stripColumns(300, 500)).isEqualTo(300);
         assertThat(AboutView.stripColumns(120, 129)).isEqualTo(120);
         assertThat(AboutView.stripColumns(60, 500)).isEqualTo(60);
     }
 
+    @Test
+    void theStripsTakeTheRoomTheNotesLeave() {
+        IntegrationInfo info = new IntegrationInfo();
+        info.pid = "1234";
+        RouteInfo r = new RouteInfo();
+        r.routeId = "stock-check";
+        info.routes.add(r);
+        MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+        ctx.timeline.merge("1234", TimelineTabRenderTest.outage(T0 / 1000 + 
299, 300, 0).json(), true);
+
+        // a 200 wide screen: the notes after a strip are short, so the strips 
get 150 columns of 2 seconds, not 100
+        assertThat(render(new AboutView(ctx), info, 200, 40)).contains("last 
5m, a column is 2s");
+    }
+
+    @Test
+    void watchCalmsDownWhenTheErrorsStopped() {
+        IntegrationInfo info = new IntegrationInfo();
+        info.pid = "1234";
+        info.errorCount = 13;
+        info.errorKinds = List.of(
+                new IntegrationInfo.ErrorKind(
+                        "stock-check", 
"io.github.resilience4j.circuitbreaker.CallNotPermittedException", 10),
+                new IntegrationInfo.ErrorKind("supplier", 
"java.net.ConnectException", 3));
+        ErrorInfo last = new ErrorInfo();
+        info.errors.add(last);
+        MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+
+        // the last error 20 seconds ago: still something to watch
+        last.timestamp = System.currentTimeMillis() - 20_000;
+        assertThat(render(new AboutView(ctx), info))
+                .contains("✗ 13 errors: CallNotPermittedException ×10 · 
ConnectException ×3 · the last 20s ago");
+
+        // an hour ago: none in the last minutes, the ones before for the 
record
+        last.timestamp = System.currentTimeMillis() - 3_600_000;
+        String rendered = render(new AboutView(ctx), info);
+        assertThat(rendered).contains("no errors in the last 5m · 13 errors 
before: CallNotPermittedException ×10")
+                .contains("the last 1h0m ago").contains("9  show 
errors").doesNotContain("✗");
+
+        // the integration says when the newest error happened, without the 
Errors tab loading them
+        info.errors = List.of();
+        info.lastErrorTimestamp = System.currentTimeMillis() - 20_000;
+        assertThat(render(new AboutView(ctx), info)).contains("✗ 13 
errors").contains("the last 20s ago");
+    }
+
     @Test
     void aTallScreenGivesTheSectionsRoom() {
         IntegrationInfo info = new IntegrationInfo();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTabStatsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTabStatsTest.java
new file mode 100644
index 000000000000..3b9decf36018
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTabStatsTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A producer has the numbers of the steps of its route that send to its 
endpoint.
+ */
+class ProducersTabStatsTest {
+
+    private IntegrationInfo info;
+    private MonitorContext ctx;
+
+    @BeforeEach
+    void setUp() {
+        Theme.resetForTesting();
+        info = new IntegrationInfo();
+        info.pid = "1234";
+        info.name = "shop";
+        ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new 
AtomicReference<>(List.of()));
+        ctx.selectedPid = "1234";
+
+        RouteInfo route = new RouteInfo();
+        route.routeId = "stock-check";
+        // two sends to the supplier, written without the // after the scheme
+        route.processors.add(step("to1", "direct:supplier", 100, 3, 1, 4, 9));
+        route.processors.add(step("to2", "direct:supplier", 20, 0, 2, 5, 7));
+        route.processors.add(step("log1", null, 120, 0, 0, 0, 0));
+        info.routes.add(route);
+    }
+
+    @Test
+    void theStepsThatSendToTheEndpointAddUp() {
+        ProducersTab.Stats st = ProducersTab.stats(info, 
producer("stock-check", "direct://supplier"));
+
+        assertThat(st.total()).isEqualTo(120);
+        assertThat(st.failed()).isEqualTo(3);
+        assertThat(st.busiest().id).isEqualTo("to1");
+    }
+
+    @Test
+    void aProducerNoStepSendsToHasNoNumbers() {
+        // a toD: the endpoint is computed per message
+        assertThat(ProducersTab.stats(info, producer("stock-check", 
"http://warehouse/stock/42";))).isNull();
+        // another route
+        assertThat(ProducersTab.stats(info, producer("orders", 
"direct://supplier"))).isNull();
+    }
+
+    @Test
+    void theTableShowsTheNumbers() {
+        info.producers.add(producer("stock-check", "direct://supplier"));
+
+        String rendered = TuiTestHelper.renderToString(new ProducersTab(ctx), 
160, 10);
+
+        
assertThat(rendered).contains("TOTAL").contains("FAIL").contains("P50/P95/P99");
+        assertThat(rendered).containsPattern("120 +3 +1ms/4ms/9ms 
+direct://supplier");
+    }
+
+    private static ProcessorInfo step(String id, String uri, long total, long 
failed, long p50, long p95, long p99) {
+        ProcessorInfo p = new ProcessorInfo();
+        p.id = id;
+        p.uri = uri;
+        p.total = total;
+        p.failed = failed;
+        p.p50Time = p50;
+        p.p95Time = p95;
+        p.p99Time = p99;
+        return p;
+    }
+
+    private static ProducerInfo producer(String routeId, String uri) {
+        ProducerInfo p = new ProducerInfo();
+        p.routeId = routeId;
+        p.uri = uri;
+        p.state = "Started";
+        p.className = "org.apache.camel.component.direct.DirectProducer";
+        return p;
+    }
+}

Reply via email to