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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 2b0a8eb2ac34 chore: camel-jbang - About: room around the sections, the 
number of errors that happened, and examples selected once started (#29429)
2b0a8eb2ac34 is described below

commit 2b0a8eb2ac34ba31c1e06f7a7c5ff3eea5b114a1
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 09:24:59 2026 +0100

    chore: camel-jbang - About: room around the sections, the number of errors 
that happened, and examples selected once started (#29429)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../ROOT/pages/camel-jbang-tui-observe.adoc        |   9 +-
 .../apache/camel/cli/connector/ErrorCounts.java    |  91 +++++
 .../camel/cli/connector/LocalCliConnector.java     |   2 +
 .../camel/cli/connector/ErrorCountsTest.java       |  92 +++++
 .../dsl/jbang/core/commands/tui/AboutView.java     | 427 +++++++++++++++------
 .../core/commands/tui/DataRefreshService.java      |  11 +-
 .../jbang/core/commands/tui/IntegrationInfo.java   |   7 +
 .../dsl/jbang/core/commands/tui/StatusParser.java  |  16 +-
 .../src/main/resources/tui/help/overview.md        |   6 +-
 .../dsl/jbang/core/commands/tui/AboutViewTest.java |  98 ++++-
 .../commands/tui/DataRefreshMembershipTest.java    |   8 +
 .../commands/tui/LaunchManagerOutcomeTest.java     |   3 +
 .../jbang/core/commands/tui/StatusParserTest.java  |  34 ++
 13 files changed, 686 insertions(+), 118 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 0124c1064b6b..0e308f1b0fdf 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -31,8 +31,9 @@ routes.
 one handed off with `seda` with `⇢`) and what it talks to, with the exchanges 
since it started and a sparkline of the
 last 5 minutes. *Up* and *Down* select a line, and *Enter* opens a route 
drilled down on the Diagram, or an endpoint with
 its details on the Endpoint tab.
-* *Last 5m* has the exchanges and how they ended, with the cause that happened 
most, and *What happened* a line per route
-over the last 5 minutes, coloured as on the Timeline, with when the worst 
happened. *+* and *-* zoom between 5, 2 and 1
+* *Last 5m* has the exchanges and how they ended, with the cause that happened 
most, and *What happened* a strip per route
+over the last 5 minutes, coloured as on the Timeline, with when the worst 
happened. The strips are as wide as the screen
+allows, and on a tall screen up to two rows high, with room around the panels. 
*+* and *-* zoom between 5, 2 and 1
 minutes (zooming in centres on the last trouble, so you see what the other 
routes did then), and *Left* and *Right*
 move a zoomed window. *4* opens the Timeline for each second.
 
@@ -160,7 +161,9 @@ Press *F5* to clear captured traces.
 == Troubleshooting Errors
 
 The Errors tab collects failures with full stack traces and exchange context.
-When errors occur, a red badge appears on the tab.
+When errors occur, a red badge appears on the tab with the number of errors 
that happened. The tab keeps only the
+newest few of each kind of error (the same route, step and exception), and its 
*COUNT* column says how many times that
+kind happened.
 
 For deeper troubleshooting, press *d* on an error to see its Diagram view: the 
path the failed
 exchange took through the routes, replayed from its message history (the same 
replay as in the
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
new file mode 100644
index 000000000000..4cdb621699cc
--- /dev/null
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ErrorCounts.java
@@ -0,0 +1,91 @@
+/*
+ * 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.cli.connector;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * How many errors happened, for the status file. The error registry keeps 
only the newest few entries of one kind of
+ * error (a route, a node and an exception type), and the newest of them 
counts how many times that kind happened; its
+ * size is the entries it kept, not the errors.
+ */
+final class ErrorCounts {
+
+    // the kinds in the status file, the most frequent first
+    static final int MAX_KINDS = 5;
+
+    private 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.
+     */
+    static void summarize(Map<String, Object> console, JsonObject summary) {
+        Map<String, Kind> kinds = new LinkedHashMap<>();
+        if (console.get("errors") instanceof List<?> entries) {
+            for (Object o : entries) {
+                if (o instanceof Map<?, ?> e) {
+                    String routeId = string(e.get("routeId"));
+                    String type = e.get("exception") instanceof Map<?, ?> ex ? 
string(ex.get("type")) : null;
+                    long count = e.get("repeatCount") instanceof Number n ? 
Math.max(1, n.longValue()) : 1;
+                    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);
+                }
+            }
+        }
+        List<Kind> sorted = new ArrayList<>(kinds.values());
+        sorted.sort(Comparator.comparingLong((Kind k) -> k.count).reversed());
+        long total = 0;
+        JsonArray top = new JsonArray();
+        for (Kind k : sorted) {
+            total += k.count;
+            if (top.size() < MAX_KINDS) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", k.routeId);
+                jo.put("exceptionType", k.exceptionType);
+                jo.put("count", k.count);
+                top.add(jo);
+            }
+        }
+        summary.put("total", total);
+        summary.put("kinds", top);
+    }
+
+    private static String string(Object o) {
+        return o != null ? o.toString() : null;
+    }
+
+    private static final class Kind {
+        private final String routeId;
+        private final String exceptionType;
+        private long count;
+
+        private Kind(String routeId, String exceptionType) {
+            this.routeId = routeId;
+            this.exceptionType = exceptionType;
+        }
+    }
+}
diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index e282133be105..d008e364d619 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -1721,6 +1721,8 @@ public class LocalCliConnector extends ServiceSupport
                     summary.put("size", json.get("size"));
                     summary.put("maximumEntries", json.get("maximumEntries"));
                     summary.put("timeToLive", json.get("timeToLive"));
+                    // size is the entries kept (a few of each kind); total is 
the errors that happened
+                    ErrorCounts.summarize(json, summary);
                     root.put("errors", summary);
                 }
             }
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
new file mode 100644
index 000000000000..2abebfb479cc
--- /dev/null
+++ 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/ErrorCountsTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.cli.connector;
+
+import java.net.ConnectException;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.console.DevConsole;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.impl.console.ErrorRegistryConsole;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The status file says how many errors happened, where the error registry 
keeps only a few entries of each kind.
+ */
+class ErrorCountsTest {
+
+    @Test
+    void theErrorsThatHappenedNotTheEntriesKept() throws Exception {
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.getErrorRegistry().setEnabled(true);
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    
from("direct:supplier").routeId("supplier").throwException(new 
ConnectException("unreachable"));
+                    from("direct:stock").routeId("stock").throwException(new 
IllegalStateException("no stock"));
+                }
+            });
+            context.start();
+            for (int i = 0; i < 10; i++) {
+                send(context, "direct:supplier");
+            }
+            for (int i = 0; i < 2; i++) {
+                send(context, "direct:stock");
+            }
+
+            ErrorRegistryConsole console = new ErrorRegistryConsole();
+            console.setCamelContext(context);
+            @SuppressWarnings("unchecked")
+            Map<String, Object> json = (Map<String, Object>) 
console.call(DevConsole.MediaType.JSON);
+            JsonObject summary = new JsonObject();
+            ErrorCounts.summarize(json, summary);
+
+            // the registry kept 3 of the 10 and both of the 2
+            assertThat(json.get("size")).isEqualTo(5);
+            assertThat(summary.getLong("total")).isEqualTo(12);
+            List<?> kinds = (List<?>) summary.get("kinds");
+            assertThat(kinds).hasSize(2);
+            JsonObject first = (JsonObject) kinds.get(0);
+            assertThat(first.getString("routeId")).isEqualTo("supplier");
+            
assertThat(first.getString("exceptionType")).isEqualTo("java.net.ConnectException");
+            assertThat(first.getLong("count")).isEqualTo(10);
+            assertThat(((JsonObject) 
kinds.get(1)).getLong("count")).isEqualTo(2);
+        }
+    }
+
+    @Test
+    void noErrors() {
+        JsonObject summary = new JsonObject();
+        ErrorCounts.summarize(Map.of("size", 0), summary);
+        assertThat(summary.getLong("total")).isZero();
+        assertThat((List<?>) summary.get("kinds")).isEmpty();
+    }
+
+    private static void send(CamelContext context, String uri) {
+        try {
+            context.createProducerTemplate().sendBody(uri, "x");
+        } catch (Exception e) {
+            // expected: the route fails
+        }
+    }
+}
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 d6d30c8f8cc3..e1e6efb42d37 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
@@ -32,6 +32,7 @@ import java.util.function.Consumer;
 
 import dev.tamboui.layout.Constraint;
 import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Padding;
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Style;
 import dev.tamboui.terminal.Frame;
@@ -63,10 +64,19 @@ final class AboutView {
     private static final long RELOAD_MILLIS = 10_000;
     private static final int HAPPENED_SECONDS = 300;
     private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
-    // the strips have this many columns, whatever the zoom
+    // 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;
-    // the sparklines of In and Out
+    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;
+    // 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;
+    // the strips of What happened are this many rows high at most, on a tall 
screen
+    private static final int MAX_STRIP_ROWS = 2;
+    // with this many rows to spare, the sections get room around them
+    private static final int ROOMY_ROWS = 12;
     private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
 
     private final MonitorContext ctx;
@@ -82,6 +92,8 @@ final class AboutView {
     // -1 follows the last second recorded
     private int zoom = TimelineTab.WINDOWS.length - 1;
     private long stripsEnd = -1;
+    // the columns of the strips as last drawn, for the title of What happened
+    private int stripColumns = STRIP_COLUMNS;
     // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
     private int cursor = -1;
     private volatile List<Target> targets = List.of();
@@ -393,20 +405,132 @@ final class AboutView {
         return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
     }
 
+    /**
+     * In, Routes and Out: their lines, and what each line is about (null for 
a line about nothing, as a sparkline).
+     */
+    private record Columns(
+            List<Line> in, List<Line> routes, List<Line> out, List<Target> 
inTargets, List<Target> routeTargets,
+            List<Target> outTargets) {
+
+        int rows() {
+            return Math.max(1, Math.max(in.size(), Math.max(routes.size(), 
out.size())));
+        }
+    }
+
     void render(Frame frame, Rect area, IntegrationInfo info) {
         // the sources are read in the background, and what they say is set on 
the render thread
         load(info, ctx.runner != null ? ctx.runner::runOnRenderThread : 
Runnable::run);
         ProjectOverview.Overview o = overview;
         About.Model m = model;
+        int width = area.width();
+        List<Line> head = head(info, o, width);
+        int spark = sparkWidth(width);
+        Columns columns = columns(o, m, info, spark, false);
+        List<Line> status = status(info, width);
+        List<Line> strips = routeStrips(info, width - 2, false, 1);
+        int compact = head.size() + 2 + columns.rows() + 2 + status.size() + 1 
+ (strips.isEmpty() ? 0 : strips.size() + 2);
+        // a tall screen: room around the sections, a blank line between the 
entries of In and Out, and between the
+        // routes of What happened when there is room for that too
+        boolean roomy = area.height() - compact >= ROOMY_ROWS;
+        if (roomy) {
+            head.add(1, Line.from(Span.raw("")));
+            columns = columns(o, m, info, spark, true);
+            // the routes, and the axis under them
+            int routes = Math.max(0, strips.size() - 1);
+            boolean spaced = area.height() - compact - ROOMY_ROWS >= routes - 
1;
+            int used = head.size() + 2 + 1 + columns.rows() + 4 + 1 + 
status.size() + 1
+                       + (routes == 0 ? 0 : routes + 1 + (spaced ? routes - 1 
: 0) + 4) + 2;
+            // the rows left over make the strips taller, keeping a little 
room under the keys
+            int tall = routes == 0 ? 1 : Math.max(1, Math.min(MAX_STRIP_ROWS, 
1 + (area.height() - used - 2) / routes));
+            strips = routeStrips(info, width - 4, spaced, tall);
+        }
+        select(columns, m);
+        String name = " " + (info.name != null ? info.name : info.pid) + " ";
+        if (roomy) {
+            renderRoomy(frame, area, info, name, head, columns, status, 
strips);
+        } else {
+            status.add(next());
+            renderCompact(frame, area, info, name, head, columns, status, 
strips);
+        }
+    }
+
+    private void renderCompact(
+            Frame frame, Rect area, IntegrationInfo info, String name, 
List<Line> head, Columns columns,
+            List<Line> status, List<Line> strips) {
+        int headHeight = head.size() + 2;
+        int available = Math.max(3, area.height() - headHeight - 
status.size());
+        int columnHeight = Math.min(columns.rows() + 2, available);
+        int statusHeight = status.size();
+        int happenedHeight = strips.isEmpty()
+                ? 0 : Math.max(0, Math.min(strips.size() + 2, area.height() - 
headHeight - columnHeight - statusHeight));
+        List<Rect> rows = Layout.vertical()
+                .constraints(Constraint.length(headHeight), 
Constraint.length(columnHeight), Constraint.length(statusHeight),
+                        Constraint.length(happenedHeight), Constraint.fill())
+                .split(area);
+        
frame.renderWidget(Paragraph.builder().text(Text.from(head)).block(block(name, 
Padding.NONE)).build(), rows.get(0));
+        renderColumns(frame, rows.get(1), columns, Padding.NONE, 0);
+        
frame.renderWidget(Paragraph.builder().text(Text.from(status)).build(), 
rows.get(2));
+        if (happenedHeight > 2) {
+            frame.renderWidget(Paragraph.builder().text(Text.from(strips))
+                    .block(block(happenedTitle(info), Padding.NONE)).build(), 
rows.get(3));
+        }
+    }
+
+    /**
+     * A tall screen: a blank line between the sections and inside the panels, 
the panels apart, the status lines in
+     * line with what the panels have, and the keys under What happened.
+     */
+    private void renderRoomy(
+            Frame frame, Rect area, IntegrationInfo info, String name, 
List<Line> head, Columns columns,
+            List<Line> status, List<Line> strips) {
+        int happenedHeight = strips.isEmpty() ? 0 : strips.size() + 4;
+        List<Rect> rows = Layout.vertical()
+                .constraints(Constraint.length(head.size() + 2), 
Constraint.length(1), Constraint.length(columns.rows() + 4),
+                        Constraint.length(1), 
Constraint.length(status.size()), Constraint.length(1),
+                        Constraint.length(happenedHeight), 
Constraint.length(1), Constraint.length(1), Constraint.fill())
+                .split(area);
+        
frame.renderWidget(Paragraph.builder().text(Text.from(head)).block(block(name, 
Padding.horizontal(1))).build(),
+                rows.get(0));
+        renderColumns(frame, rows.get(2), columns, Padding.uniform(1), 1);
+        
frame.renderWidget(Paragraph.builder().text(Text.from(status)).build(), 
indent(rows.get(4)));
+        if (happenedHeight > 2) {
+            frame.renderWidget(Paragraph.builder().text(Text.from(strips))
+                    .block(block(happenedTitle(info), 
Padding.uniform(1))).build(), rows.get(6));
+        }
+        
frame.renderWidget(Paragraph.builder().text(Text.from(next())).build(), 
indent(rows.get(8)));
+    }
+
+    /** The area without the two columns the panels have before their text: 
their border and their padding. */
+    private static Rect indent(Rect r) {
+        return new Rect(r.x() + 2, r.y(), Math.max(0, r.width() - 2), 
r.height());
+    }
+
+    private static void renderColumns(Frame frame, Rect area, Columns columns, 
Padding padding, int spacing) {
+        // by hand: the layout leaves the last column short of the edge when 
spaced
+        int w0 = area.width() * 28 / 100;
+        int w1 = area.width() * 42 / 100;
+        int w2 = Math.max(0, area.width() - w0 - w1 - 2 * spacing);
+        List<Rect> cols = List.of(new Rect(area.x(), area.y(), w0, 
area.height()),
+                new Rect(area.x() + w0 + spacing, area.y(), w1, area.height()),
+                new Rect(area.x() + w0 + w1 + 2 * spacing, area.y(), w2, 
area.height()));
+        
frame.renderWidget(Paragraph.builder().text(Text.from(columns.in())).block(block("
 In ", padding)).build(),
+                cols.get(0));
+        
frame.renderWidget(Paragraph.builder().text(Text.from(columns.routes())).block(block("
 Routes ", padding)).build(),
+                cols.get(1));
+        
frame.renderWidget(Paragraph.builder().text(Text.from(columns.out())).block(block("
 Out ", padding)).build(),
+                cols.get(2));
+    }
+
+    /** What the integration is, and its purpose: the comment at the top of 
its main file. */
+    private List<Line> head(IntegrationInfo info, ProjectOverview.Overview o, 
int width) {
         String p = purpose;
         String pFrom = purposeFrom;
-        int width = area.width();
         List<Line> head = new ArrayList<>();
         head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)),
                 Span.styled(info.vanishing ? "  ○ stopped" : "  ● running",
                         info.vanishing ? Theme.muted() : Theme.success())));
         if (p != null) {
-            for (String l : wrap(p, Math.max(20, width - 6), 2)) {
+            for (String l : wrap(p, Math.max(20, width - 8), 2)) {
                 head.add(Line.from(Span.styled(" " + l, Style.EMPTY.bold())));
             }
             head.add(Line.from(Span.styled(" " + pFrom, Theme.muted())));
@@ -417,32 +541,26 @@ final class AboutView {
         } else {
             head.add(Line.from(Span.styled(error != null ? " " + error : " 
Reading the sources…", Theme.muted())));
         }
+        return head;
+    }
 
-        List<Line> in = new ArrayList<>();
-        List<Line> routes = new ArrayList<>();
-        List<Line> out = new ArrayList<>();
-        List<Target> inTargets = new ArrayList<>();
-        List<Target> routeTargets = new ArrayList<>();
-        List<Target> outTargets = new ArrayList<>();
-        if (o != null && m != null) {
-            columns(m, info, in, routes, out, inTargets, routeTargets, 
outTargets);
-        }
-        // the cursor goes down In, then Routes, then Out
+    /** The lines the cursor goes over, down In, then Routes, then Out; the 
line it is on in the selection colour. */
+    private void select(Columns c, About.Model m) {
         List<Target> all = new ArrayList<>();
         List<String> labels = new ArrayList<>();
         List<String> inLabels = new ArrayList<>();
-        if (o != null && m != null) {
+        if (m != null) {
             m.in().stream().filter(e -> e.route() != null).forEach(e -> 
inLabels.add(e.label()));
         }
         String[] names = { "in", "route", "out" };
-        List<List<Target>> columns = List.of(inTargets, routeTargets, 
outTargets);
-        for (int c = 0; c < columns.size(); c++) {
+        List<List<Target>> columns = List.of(c.inTargets(), c.routeTargets(), 
c.outTargets());
+        for (int i = 0; i < columns.size(); i++) {
             int n = 0;
-            for (Target t : columns.get(c)) {
+            for (Target t : columns.get(i)) {
                 if (t != null) {
                     all.add(t);
                     // what an agent sees of the line: its column, and what it 
is about
-                    labels.add(names[c] + ": " + (c == 0 && n < 
inLabels.size() ? inLabels.get(n) + " → " + t.routeId()
+                    labels.add(names[i] + ": " + (i == 0 && n < 
inLabels.size() ? inLabels.get(n) + " → " + t.routeId()
                             : t.endpoint() != null ? t.endpoint() + " from " + 
t.routeId() : t.routeId()));
                     n++;
                 }
@@ -453,34 +571,7 @@ final class AboutView {
         if (cursor >= all.size()) {
             cursor = all.size() - 1;
         }
-        highlight(in, inTargets, routes, routeTargets, out, outTargets);
-        int columnRows = Math.max(1, Math.max(in.size(), 
Math.max(routes.size(), out.size())));
-        List<Line> strips = strips(info, width);
-        int headHeight = head.size() + 2;
-        int available = Math.max(3, area.height() - headHeight - 
strips.size());
-        int columnHeight = Math.min(columnRows + 2, available);
-
-        List<Line> routeStrips = routeStrips(info, width);
-        int stripsHeight = strips.size();
-        int happenedHeight = routeStrips.isEmpty()
-                ? 0 : Math.max(0, Math.min(routeStrips.size() + 2, 
area.height() - headHeight - columnHeight - stripsHeight));
-        List<Rect> rows = Layout.vertical()
-                .constraints(Constraint.length(headHeight), 
Constraint.length(columnHeight), Constraint.length(stripsHeight),
-                        Constraint.length(happenedHeight), Constraint.fill())
-                .split(area);
-        if (happenedHeight > 2) {
-            frame.renderWidget(Paragraph.builder().text(Text.from(routeStrips))
-                    .block(block(happenedTitle(info))).build(), rows.get(3));
-        }
-        frame.renderWidget(Paragraph.builder().text(Text.from(head))
-                .block(block(" " + (info.name != null ? info.name : info.pid) 
+ " ")).build(), rows.get(0));
-        List<Rect> cols = Layout.horizontal()
-                .constraints(Constraint.percentage(28), 
Constraint.percentage(42), Constraint.percentage(30))
-                .split(rows.get(1));
-        
frame.renderWidget(Paragraph.builder().text(Text.from(in)).block(block(" In 
")).build(), cols.get(0));
-        
frame.renderWidget(Paragraph.builder().text(Text.from(routes)).block(block(" 
Routes ")).build(), cols.get(1));
-        
frame.renderWidget(Paragraph.builder().text(Text.from(out)).block(block(" Out 
")).build(), cols.get(2));
-        
frame.renderWidget(Paragraph.builder().text(Text.from(strips)).build(), 
rows.get(2));
+        highlight(c.in(), c.inTargets(), c.routes(), c.routeTargets(), 
c.out(), c.outTargets());
     }
 
     /**
@@ -497,11 +588,15 @@ final class AboutView {
         if (seconds < Timeline.SECONDS) {
             keys += ", ← → move";
         }
-        return " What happened ─ " + when + ", a column is " + seconds / 
STRIP_COLUMNS + "s ─ " + keys
+        return " What happened ─ " + when + ", a column is " + seconds / 
stripColumns + "s ─ " + keys
                + ", 4 for each second ";
     }
 
-    private List<Line> routeStrips(IntegrationInfo info, int width) {
+    /**
+     * A strip per route, {@code tall} rows high (the blocks stacked, so a 
strip of 2 rows has 16 levels), with a blank
+     * line between the routes when spaced, and the time axis under them.
+     */
+    private List<Line> routeStrips(IntegrationInfo info, int width, boolean 
spaced, int tall) {
         List<Line> lines = new ArrayList<>();
         long last = ctx.timeline.endSecond(info.pid);
         if (last < 0) {
@@ -512,12 +607,18 @@ 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 per = seconds / STRIP_COLUMNS;
-        int columns = STRIP_COLUMNS;
+        int columns = stripColumns(seconds, width - label - STRIP_NOTES);
+        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)) {
-            List<Span> spans = new ArrayList<>();
+            if (spaced && !lines.isEmpty()) {
+                lines.add(Line.from(Span.raw("")));
+            }
             String name = r.route().length() > label - 3 ? 
r.route().substring(0, label - 4) + "…" : r.route();
-            spans.add(Span.raw("   " + name + " ".repeat(Math.max(1, label - 3 
- name.length()))));
             long max = 1;
             // completed, recovered, handled, failed, down, redelivery 
attempts: the height is the exchanges done, the
             // attempts only colour the column (an exchange retried many times 
is still one exchange)
@@ -535,59 +636,109 @@ final class AboutView {
             for (long[] c : col) {
                 max = Math.max(max, c[0] + c[1] + c[2] + c[3]);
             }
-            for (long[] c : col) {
-                long t = c[0] + c[1] + c[2] + c[3];
+            List<List<Span>> rows = stripCells(col, max, tall, before);
+            // the name and the notes on the bottom row, where the blocks stand
+            for (int row = 0; row < tall; row++) {
+                List<Span> spans = new ArrayList<>();
+                spans.add(Span.raw(row == tall - 1
+                        ? "   " + 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));
+                }
+                lines.add(Line.from(spans));
+            }
+        }
+        // the time axis: the first second of the window and the last
+        String from = TIME.format(Instant.ofEpochSecond(end - seconds + 1));
+        String to = stripsEnd >= 0 ? TIME.format(Instant.ofEpochSecond(end)) : 
"now";
+        lines.add(Line.from(Span.styled(" ".repeat(label) + from
+                                        + " ".repeat(Math.max(1, columns - 
from.length() - to.length())) + to,
+                Theme.muted())));
+        return lines;
+    }
+
+    /**
+     * The cells of a strip, row by row from the top: a column coloured by the 
worst that happened in it, an outage and
+     * an open circuit breaker the full height, nothing a dot at the bottom, 
and the first columns blank (from before
+     * the integration started).
+     */
+    private static List<List<Span>> stripCells(long[][] col, long max, int 
tall, int before) {
+        List<List<Span>> rows = new ArrayList<>();
+        for (int row = 0; row < tall; row++) {
+            rows.add(new ArrayList<>());
+        }
+        for (int k = 0; k < col.length; k++) {
+            long[] c = col[k];
+            long t = c[0] + c[1] + c[2] + c[3];
+            for (int row = 0; row < tall; row++) {
+                boolean bottom = row == tall - 1;
+                Span cell;
                 if (c[4] > 0) {
-                    spans.add(Span.styled("░", Theme.error()));
+                    cell = Span.styled("░", Theme.error());
                 } else if (c[6] > 0) {
                     // a circuit breaker of the route was open (half open 
dim), as on the Timeline
-                    spans.add(Span.styled("▒", c[6] >= 2 ? Theme.notice() : 
Theme.muted()));
+                    cell = Span.styled("▒", c[6] >= 2 ? Theme.notice() : 
Theme.muted());
                 } else if (t == 0 && c[5] == 0) {
-                    spans.add(Span.styled("·", Theme.muted()));
+                    cell = bottom && k >= before ? Span.styled("·", 
Theme.muted()) : Span.raw(" ");
                 } else {
-                    int level = (int) Math.max(1, Math.min(8, Math.ceil(t * 
8.0 / max)));
+                    int level = (int) Math.max(1, Math.min(8L * tall, 
Math.ceil(t * 8.0 * tall / max)));
+                    int here = Math.max(0, Math.min(8, level - (tall - 1 - 
row) * 8));
                     Style style = c[3] > 0 ? Theme.error()
                             : c[2] > 0 ? Style.EMPTY.fg(Theme.ACCENT)
                             : c[1] > 0 || c[5] > 0 ? Theme.warning() : 
Theme.success();
-                    
spans.add(Span.styled(String.valueOf(BLOCKS.charAt(level)), style));
+                    cell = Span.styled(String.valueOf(BLOCKS.charAt(here)), 
style);
                 }
+                rows.get(row).add(cell);
             }
-            long done = r.total(r.completed()) + r.total(r.recovered()) + 
r.total(r.handled()) + r.total(r.failed());
-            spans.add(Span.styled("  " + done, Theme.success()));
-            if (r.openSeconds() > 0) {
-                spans.add(Span.styled("  ⚡ open " + r.openSeconds() + "s " + 
ago(last - lastAt(r.open(), end, seconds)),
-                        Theme.notice()));
-            }
-            // the worst that happened, when it last did, and for a recovery 
the failure it recovered from (a failed or
-            // handled one has its cause on the Last 5m line and on the 
Timeline)
-            long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
-                    : r.total(r.recovered()) > 0 ? r.recovered() : null;
-            if (worst != null) {
-                String what = worst == r.failed() ? " failed" : worst == 
r.handled() ? " handled" : " recovered";
-                Style style = worst == r.failed() ? Theme.error()
-                        : worst == r.handled() ? Style.EMPTY.fg(Theme.ACCENT) 
: Theme.warning();
-                long at = lastAt(worst, end, seconds);
-                spans.add(Span.styled("  " + r.total(worst) + what, style));
-                spans.add(Span.styled(" " + ago(last - at), Theme.muted()));
-                String cause = worst == r.recovered() ? recoveredFrom(info, 
r.route(), end - seconds + 1, end) : null;
-                if (cause != null) {
-                    int room = width - label - columns - 40;
-                    if (room > 10) {
-                        spans.add(Span.styled(" · ", Theme.muted()));
-                        spans.add(Span.styled(cause.length() > room ? 
cause.substring(0, room - 1) + "…" : cause,
-                                Theme.warning()));
-                    }
-                }
+        }
+        return rows;
+    }
+
+    /**
+     * What comes after the strip of a route: its exchanges, the seconds its 
circuit breaker was open, and the worst
+     * that happened, when it last did, and for a recovery the failure it 
recovered from (a failed or handled one has
+     * its cause on the Last 5m line and on the Timeline).
+     */
+    private List<Span> stripNotes(IntegrationInfo info, Timeline.RouteSeries 
r, long last, long end, int seconds, int room) {
+        List<Span> spans = new ArrayList<>();
+        long done = r.total(r.completed()) + r.total(r.recovered()) + 
r.total(r.handled()) + r.total(r.failed());
+        spans.add(Span.styled("  " + done, Theme.success()));
+        if (r.openSeconds() > 0) {
+            spans.add(Span.styled("  ⚡ open " + r.openSeconds() + "s " + 
ago(last - lastAt(r.open(), end, seconds)),
+                    Theme.notice()));
+        }
+        long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                : r.total(r.recovered()) > 0 ? r.recovered() : null;
+        if (worst != null) {
+            String what = worst == r.failed() ? " failed" : worst == 
r.handled() ? " handled" : " recovered";
+            Style style = worst == r.failed() ? Theme.error()
+                    : worst == r.handled() ? Style.EMPTY.fg(Theme.ACCENT) : 
Theme.warning();
+            long at = lastAt(worst, end, seconds);
+            spans.add(Span.styled("  " + r.total(worst) + what, style));
+            spans.add(Span.styled(" " + ago(last - at), Theme.muted()));
+            String cause = worst == r.recovered() ? recoveredFrom(info, 
r.route(), end - seconds + 1, end) : null;
+            if (cause != null && room > 10) {
+                spans.add(Span.styled(" · ", Theme.muted()));
+                spans.add(Span.styled(cause.length() > room ? 
cause.substring(0, room - 1) + "…" : cause,
+                        Theme.warning()));
             }
-            lines.add(Line.from(spans));
         }
-        // the time axis: the first second of the window and the last
-        String from = TIME.format(Instant.ofEpochSecond(end - seconds + 1));
-        String to = stripsEnd >= 0 ? TIME.format(Instant.ofEpochSecond(end)) : 
"now";
-        lines.add(Line.from(Span.styled(" ".repeat(label) + from
-                                        + " ".repeat(Math.max(1, columns - 
from.length() - to.length())) + to,
-                Theme.muted())));
-        return lines;
+        return spans;
+    }
+
+    /**
+     * 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}.
+     */
+    static int stripColumns(int seconds, int room) {
+        int best = Math.min(STRIP_COLUMNS, seconds);
+        for (int c = best + 1; c <= Math.min(MAX_STRIP_COLUMNS, 
Math.min(seconds, room)); c++) {
+            if (seconds % c == 0) {
+                best = c;
+            }
+        }
+        return best;
     }
 
     /** The last second of the window with a value, or the end. */
@@ -676,9 +827,23 @@ final class AboutView {
         }
     }
 
-    private void columns(
-            About.Model m, IntegrationInfo info, List<Line> in, List<Line> 
routes, List<Line> out,
-            List<Target> inTargets, List<Target> routeTargets, List<Target> 
outTargets) {
+    private Columns columns(ProjectOverview.Overview o, About.Model m, 
IntegrationInfo info, int spark, boolean spaced) {
+        Columns c = new Columns(
+                new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new 
ArrayList<>(), new ArrayList<>(),
+                new ArrayList<>());
+        if (o != null && m != null) {
+            columns(m, info, spark, spaced, c);
+        }
+        return c;
+    }
+
+    private void columns(About.Model m, IntegrationInfo info, int spark, 
boolean spaced, Columns c) {
+        List<Line> in = c.in();
+        List<Line> routes = c.routes();
+        List<Line> out = c.out();
+        List<Target> inTargets = c.inTargets();
+        List<Target> routeTargets = c.routeTargets();
+        List<Target> outTargets = c.outTargets();
         Map<String, Long> totals = new LinkedHashMap<>();
         for (RouteInfo r : info.routes) {
             if (r.routeId != null) {
@@ -686,6 +851,10 @@ final class AboutView {
             }
         }
         for (About.In e : m.in()) {
+            if (spaced && !in.isEmpty()) {
+                in.add(Line.from(Span.raw("")));
+                inTargets.add(null);
+            }
             Long total = e.route() != null ? totals.get(e.route()) : null;
             in.add(Line.from(Span.raw(" " + icon(e.kind()) + " "), 
Span.styled(e.label(), Style.EMPTY.bold()),
                     Span.styled(total != null ? "  " + total : "", 
Theme.success())));
@@ -693,7 +862,7 @@ final class AboutView {
             // the exchanges of the route it starts over the last minutes, and 
the rate now
             long[] perSecond = e.route() != null ? routePerSecond(info, 
e.route()) : null;
             if (perSecond != null) {
-                in.add(sparkLine("   ", perSecond));
+                in.add(sparkLine("   ", perSecond, spark));
                 inTargets.add(null);
             }
         }
@@ -719,6 +888,10 @@ final class AboutView {
         }
 
         for (About.Out s : m.out()) {
+            if (spaced && !out.isEmpty()) {
+                out.add(Line.from(Span.raw("")));
+                outTargets.add(null);
+            }
             Long hits = outHits(info, s.uri(), s.route());
             out.add(Line.from(Span.raw(" " + icon(s.category()) + " "), 
Span.styled(shortUri(s.uri()), Style.EMPTY.bold()),
                     Span.styled(hits != null ? "  " + hits : "", 
Theme.success())));
@@ -729,7 +902,8 @@ final class AboutView {
             // minutes, where a history of the sends would start with the TUI
             long[] perSecond = s.route() != null ? routePerSecond(info, 
s.route()) : null;
             if (perSecond != null) {
-                from.addAll(sparkLine("  ", perSecond).spans());
+                from.addAll(sparkLine("  ", perSecond, Math.max(8, 
Math.min(spark, spark + 10 - s.route().length())))
+                        .spans());
             }
             out.add(Line.from(from));
             outTargets.add(null);
@@ -761,14 +935,20 @@ final class AboutView {
     }
 
     /** The values as a sparkline and the rate now, or only idle when there 
was nothing. */
-    private static Line sparkLine(String indent, long[] values) {
+    private static Line sparkLine(String indent, long[] values, int width) {
         if (Arrays.stream(values).allMatch(v -> v <= 0)) {
             return Line.from(Span.raw(indent), Span.styled("idle", 
Theme.muted()));
         }
-        return Line.from(Span.raw(indent), Span.styled(spark(values, 
SPARK_WIDTH), Theme.info()),
+        return Line.from(Span.raw(indent), Span.styled(spark(values, width), 
Theme.info()),
                 Span.styled("  " + rate(values), Theme.muted()));
     }
 
+    /** The sparklines of In fill its column, between {@value #SPARK_WIDTH} 
and {@value #MAX_SPARK_WIDTH} wide. */
+    private static int sparkWidth(int width) {
+        // the column less its border and padding, the indent and the rate
+        return Math.max(SPARK_WIDTH, Math.min(MAX_SPARK_WIDTH, width * 28 / 
100 - 4 - 3 - 8));
+    }
+
     /** The rate now: the mean of the last 10 seconds, or idle. */
     static String rate(long[] values) {
         int n = Math.min(10, values.length);
@@ -830,14 +1010,33 @@ final class AboutView {
 
     // ---- what has happened, and what to watch ----
 
-    private List<Line> strips(IntegrationInfo info, int width) {
+    /** The last minutes, and what to watch. */
+    private List<Line> status(IntegrationInfo info, int width) {
         List<Line> lines = new ArrayList<>();
         lines.add(happened(info, width));
         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()));
-            watch.add(Span.styled(" (9 Errors)", Theme.muted()));
+            // 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()));
+                }
+                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()));
+            }
+            // 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("   "));
+            watch.add(Span.styled(" 9 ", Theme.hintKey()));
+            watch.add(Span.raw(" show errors"));
         } else {
             watch.add(Span.styled("no errors", Theme.success()));
         }
@@ -852,11 +1051,23 @@ final class AboutView {
             watch.add(Span.raw(" · " + inflight + " in flight"));
         }
         lines.add(Line.from(watch));
-        lines.add(Line.from(Span.styled(" Next     ", Theme.muted()), 
Span.styled(" 4 ", Theme.hintKey()),
+        return lines;
+    }
+
+    private static String simpleName(String type) {
+        if (type == null) {
+            return "error";
+        }
+        int dot = type.lastIndexOf('.');
+        return dot >= 0 ? type.substring(dot + 1) : type;
+    }
+
+    /** Where to go next. */
+    private static Line next() {
+        return Line.from(Span.styled(" Next     ", Theme.muted()), 
Span.styled(" 4 ", Theme.hintKey()),
                 Span.raw(" what happened, per second  "), Span.styled(" 5 ", 
Theme.hintKey()),
                 Span.raw(" diagram  "), Span.styled(" 2 ", Theme.hintKey()), 
Span.raw(" source  "),
-                Span.styled(" v ", Theme.hintKey()), Span.raw(" all 
integrations")));
-        return lines;
+                Span.styled(" v ", Theme.hintKey()), Span.raw(" all 
integrations"));
     }
 
     /**
@@ -1009,7 +1220,7 @@ final class AboutView {
         return lines;
     }
 
-    private static Block block(String title) {
-        return 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build();
+    private static Block block(String title, Padding padding) {
+        return 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).padding(padding).build();
     }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
index 85740cbcac92..69313a523c98 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
@@ -481,6 +481,15 @@ class DataRefreshService {
         return null;
     }
 
+    /**
+     * Whether the integration is the one launched: an example is launched by 
its catalog name
+     * (fail-well/circuit-breaker) and names itself without the category 
(circuit-breaker).
+     */
+    static boolean isLaunched(String launched, String name) {
+        return name != null
+                && (name.equalsIgnoreCase(launched) || 
name.equalsIgnoreCase(TuiHelper.stripCategory(launched)));
+    }
+
     private void mergePhantoms(List<IntegrationInfo> infos) {
         mergePhantoms(infos, ctx.phantomIntegrations, ctx);
     }
@@ -500,7 +509,7 @@ class DataRefreshService {
         String autoSelect = refreshCtx.getPendingAutoSelect();
         if (autoSelect != null) {
             for (IntegrationInfo info : infos) {
-                if (!info.vanishing && autoSelect.equalsIgnoreCase(info.name)) 
{
+                if (!info.vanishing && isLaunched(autoSelect, info.name)) {
                     ctx.selectedPid = info.pid;
                     ctx.lastSelectedName = null;
                     refreshCtx.clearPendingAutoSelect();
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 eead180378c3..9df5ad327f7b 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
@@ -21,6 +21,11 @@ import java.util.List;
 import java.util.Set;
 
 class IntegrationInfo {
+
+    /** A kind of error that happened: in a route, of an exception type, this 
many times. */
+    record ErrorKind(String routeId, String exceptionType, long count) {
+    }
+
     String pid;
     String name;
     String camelVersion;
@@ -109,7 +114,9 @@ class IntegrationInfo {
     // the chaos rules and report (camel-chaos), or null without camel-chaos
     ChaosInfo chaos;
     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;
+    volatile List<ErrorKind> errorKinds = List.of();
     volatile List<ErrorInfo> errors = new ArrayList<>();
     boolean inflightBrowseEnabled;
     final List<InflightInfo> inflightExchanges = new ArrayList<>();
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 95dae7b7a3c3..99076064e21b 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
@@ -112,10 +112,22 @@ final class StatusParser {
         // Parse Kafka consumers
         parseKafkaSection(root, info);
 
-        // Parse error count from error registry
+        // the errors that happened, from the error registry: its size is the 
entries it kept, a few of each kind,
+        // so the total when the integration says it (Camel 4.24)
         JsonObject errorsObj = (JsonObject) root.get("errors");
         if (errorsObj != null) {
-            info.errorCount = errorsObj.getIntegerOrDefault("size", 0);
+            info.errorCount = errorsObj.get("total") instanceof Number total
+                    ? (int) Math.min(Integer.MAX_VALUE, total.longValue()) : 
errorsObj.getIntegerOrDefault("size", 0);
+            List<IntegrationInfo.ErrorKind> kinds = new ArrayList<>();
+            if (errorsObj.get("kinds") instanceof List<?> list) {
+                for (Object o : list) {
+                    if (o instanceof JsonObject k) {
+                        kinds.add(new IntegrationInfo.ErrorKind(
+                                k.getString("routeId"), 
k.getString("exceptionType"), k.getLongOrDefault("count", 1)));
+                    }
+                }
+            }
+            info.errorKinds = List.copyOf(kinds);
         }
 
         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 67373be6b69d..8dcd6594f3c9 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,10 +28,12 @@ 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** — errors, routes not started, exchanges in flight.
+- **Watch** — the errors that happened and the kinds that happened most,
+  routes not started, exchanges in flight.
 - **Next** — the keys that lead deeper: **4** the Timeline, **5** the
   Diagram, **2** the source.
-- **What happened** — a line per route over the last 5 minutes, coloured
+- **What happened** — a strip per route over the last 5 minutes (as wide
+  as the screen allows, and up to two rows high on a tall one), coloured
   as on the Timeline: green completed, yellow recovered by redelivery,
   orange handled by the error handler, red failed, a red `░` a second a
   chaos rule had the route down, a `▒` a second a circuit breaker of the
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 d3e9cfacab59..e867bb9cc6b4 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
@@ -50,7 +50,11 @@ class AboutViewTest {
     Path dir;
 
     private static String render(AboutView about, IntegrationInfo info) {
-        Rect area = new Rect(0, 0, 160, 34);
+        return render(about, info, 160, 34);
+    }
+
+    private static String render(AboutView about, IntegrationInfo info, int 
width, int height) {
+        Rect area = new Rect(0, 0, width, height);
         Buffer buffer = Buffer.empty(area);
         about.render(Frame.forTesting(buffer), area, info);
         return TuiTestHelper.bufferToString(buffer);
@@ -107,7 +111,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);
-        assertThat(render(about, info)).contains("What happened ─ last 5m, a 
column is 5s ─ + zoom in, 4 for each");
+        // 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");
 
         assertThat(about.handleKeyEvent(KeyEvent.ofChar('+'), info)).isTrue();
         // 2 minutes from the first second recorded, with the outage in it
@@ -124,6 +129,95 @@ class AboutViewTest {
         assertThat(about.handleKeyEvent(KeyEvent.ofChar('x'), info)).isFalse();
     }
 
+    @Test
+    void theStripsAreAsWideAsTheRoomAllows() {
+        // a whole number of seconds in a column, at least 60 columns and at 
most 150
+        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(120, 129)).isEqualTo(120);
+        assertThat(AboutView.stripColumns(60, 500)).isEqualTo(60);
+    }
+
+    @Test
+    void aTallScreenGivesTheSectionsRoom() {
+        IntegrationInfo info = new IntegrationInfo();
+        info.pid = "1234";
+        for (String id : List.of("orders", "stock")) {
+            RouteInfo r = new RouteInfo();
+            r.routeId = id;
+            info.routes.add(r);
+        }
+        MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+        TimelineJson t = new TimelineJson(T0 / 1000 + 299, 300);
+        for (int i = 0; i < 300; i++) {
+            t.route("orders", "completed", i, 2);
+            t.route("stock", "completed", i, 1);
+        }
+        ctx.timeline.merge("1234", t.json(), true);
+        AboutView about = new AboutView(ctx);
+
+        // a tall screen: the panels apart, a blank line between the routes, 
and the strips 2 rows high
+        String tall = render(about, info, 198, 48);
+        assertThat(tall).contains("╮ ╭");
+        // the panels end where the others do, at the right edge
+        assertThat(tall.lines().filter(l -> l.contains("╮ 
╭")).findFirst().orElseThrow().stripTrailing()).hasSize(198);
+        // (the strips are in the What happened panel; the Last 5m line has 
its own sparkline)
+        List<String> strip = tall.lines().filter(l -> l.startsWith("│") && 
l.contains("██████████")).toList();
+        assertThat(strip).hasSize(4);
+        assertThat(strip.get(1)).contains("orders");
+        assertThat(strip.get(0)).doesNotContain("orders");
+        assertThat(tall.lines().filter(l -> 
l.contains("Next")).findFirst().orElseThrow()).startsWith("   Next");
+
+        // a short one: as compact as before
+        String compact = render(about, info, 198, 16);
+        assertThat(compact).contains("╮╭");
+        assertThat(compact.lines().filter(l -> l.startsWith("│") && 
l.contains("██████████")).count()).isEqualTo(2);
+    }
+
+    @Test
+    void watchSaysTheErrorsThatHappenedAndTheirKinds() {
+        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));
+        MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+        assertThat(render(new AboutView(ctx), info))
+                .contains("✗ 13 errors: CallNotPermittedException ×10 · 
ConnectException ×3    9  show errors");
+
+        info.errorCount = 3;
+        info.errorKinds = List.of(new IntegrationInfo.ErrorKind("supplier", 
"java.net.ConnectException", 3));
+        assertThat(render(new AboutView(ctx), info)).contains("✗ 3 errors: 
ConnectException    9  show errors");
+    }
+
+    @Test
+    void theTimeBeforeTheIntegrationStartedIsBlank() {
+        IntegrationInfo info = new IntegrationInfo();
+        info.pid = "1234";
+        RouteInfo r = new RouteInfo();
+        r.routeId = "orders";
+        info.routes.add(r);
+        long end = T0 / 1000 + 299;
+        // started 100 seconds ago, idle for its first 10
+        info.uptime = (end - 99) * 1000;
+        MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+        TimelineJson t = new TimelineJson(end, 300);
+        for (int i = 210; i < 300; i++) {
+            t.route("orders", "completed", i, 1);
+        }
+        ctx.timeline.merge("1234", t.json(), true);
+        String line = render(new AboutView(ctx), info, 198, 
16).lines().filter(l -> l.contains("orders ")).findFirst()
+                .orElseThrow();
+        // 100 columns of 3 seconds: 66 blank before it started, then dots for 
the seconds it did nothing
+        String strip = line.substring(line.indexOf("orders") + 
"orders".length()).stripLeading();
+        assertThat(strip).startsWith("···").doesNotContain("· ·");
+        assertThat(line.indexOf('·') - 
line.indexOf("orders")).isGreaterThan(60);
+    }
+
     @Test
     void aRecoveryShowsWhenAndTheFailureItRecoveredFrom() {
         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/DataRefreshMembershipTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java
index 55cd24f50c38..7f59653f8e34 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java
@@ -109,6 +109,14 @@ class DataRefreshMembershipTest {
         assertThat(calls.get()).isEqualTo(1);
     }
 
+    @Test
+    void anExampleIsSelectedByTheNameItGivesItself() {
+        assertThat(DataRefreshService.isLaunched("fail-well/circuit-breaker", 
"circuit-breaker")).isTrue();
+        assertThat(DataRefreshService.isLaunched("orders", "Orders")).isTrue();
+        assertThat(DataRefreshService.isLaunched("fail-well/circuit-breaker", 
"retry")).isFalse();
+        assertThat(DataRefreshService.isLaunched("orders", null)).isFalse();
+    }
+
     private static DataRefreshService newService() {
         return new DataRefreshService(
                 "test",
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
index 0aa68cc8fec5..0a427ad123eb 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.condition.DisabledOnOs;
 import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -40,6 +41,8 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
  * and camel_control waits for how it went.
  */
 @DisabledOnOs(OS.WINDOWS)
+// it moves the home directory of the CLI, where other tests would look for 
theirs
+@Isolated
 class LaunchManagerOutcomeTest {
 
     @TempDir
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
index fd1177b48885..974a290dcd93 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
@@ -240,6 +240,40 @@ class StatusParserTest {
         assertEquals(3, info.errorCount);
     }
 
+    @Test
+    void theErrorsThatHappenedNotTheEntriesKept() {
+        JsonObject root = new JsonObject();
+        JsonObject context = new JsonObject();
+        context.put("name", "errorTest");
+        root.put("context", context);
+        // the registry kept 6 entries, 3 of each kind, of the 13 errors that 
happened
+        JsonObject errorsObj = new JsonObject();
+        errorsObj.put("size", 6);
+        errorsObj.put("total", 13L);
+        JsonArray kinds = new JsonArray();
+        kinds.add(kind("stock-check", 
"io.github.resilience4j.circuitbreaker.CallNotPermittedException", 10));
+        kinds.add(kind("supplier", "java.net.ConnectException", 3));
+        errorsObj.put("kinds", kinds);
+        root.put("errors", errorsObj);
+
+        IntegrationInfo info = 
StatusParser.parseIntegration(ProcessHandle.current(), root);
+
+        assertNotNull(info);
+        assertEquals(13, info.errorCount);
+        assertEquals(List.of(
+                new IntegrationInfo.ErrorKind(
+                        "stock-check", 
"io.github.resilience4j.circuitbreaker.CallNotPermittedException", 10),
+                new IntegrationInfo.ErrorKind("supplier", 
"java.net.ConnectException", 3)), info.errorKinds);
+    }
+
+    private static JsonObject kind(String routeId, String type, long count) {
+        JsonObject k = new JsonObject();
+        k.put("routeId", routeId);
+        k.put("exceptionType", type);
+        k.put("count", count);
+        return k;
+    }
+
     @Test
     void parseIntegrationWithInflight() {
         JsonObject root = new JsonObject();

Reply via email to