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

davsclaus pushed a commit to branch quick-fix/polish-tables
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 443a1a487c052a8d387d5f0d658912776cb76e17
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 17:33:08 2026 +0200

    chore: camel-jbang - tables read better: a first row selected for the 
detail, URIs and durations as people write them, Activity newest first with a 
true rate, one row per REST service and health check
    
    - Errors, Endpoints and HTTP select their first row, so the detail panel 
shows something
    - endpoint URIs are shown decoded ({sku}, ${header...}) in Routes, Activity 
and Endpoints
    - durations read 2.02s rather than 2s20ms
    - Activity: newest first, the rate over the time up to now (a burst was 
thousands a minute), a short exchange id, and a detail panel as tall as its 
content
    - HTTP: a REST service and its platform-http consumer are one row
    - Health: a check reported for readiness and liveness is one row; the title 
says what R and L are
    - Endpoints: a check mark for stub and remote
    - tui_navigate finds the tabs by the label the bar shows (Route, Endpoint) 
and by number
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../jbang/core/commands/tui/AbstractTableTab.java  | 10 ++++
 .../dsl/jbang/core/commands/tui/ActivityTab.java   | 68 ++++++++++++++--------
 .../dsl/jbang/core/commands/tui/EndpointsTab.java  | 11 +++-
 .../dsl/jbang/core/commands/tui/ErrorsTab.java     |  5 ++
 .../dsl/jbang/core/commands/tui/HealthTab.java     |  5 +-
 .../dsl/jbang/core/commands/tui/HistoryTab.java    |  2 +-
 .../camel/dsl/jbang/core/commands/tui/HttpTab.java | 32 +++++++++-
 .../dsl/jbang/core/commands/tui/McpFacade.java     | 25 ++++++++
 .../dsl/jbang/core/commands/tui/RoutesTab.java     |  5 +-
 .../dsl/jbang/core/commands/tui/StatusParser.java  | 22 ++++++-
 .../dsl/jbang/core/commands/tui/TuiHelper.java     | 22 ++++++-
 .../core/commands/tui/DisplayFormatsTest.java      | 58 ++++++++++++++++++
 .../core/commands/tui/EndpointsTabRenderTest.java  |  4 ++
 .../core/commands/tui/ErrorsTabRenderTest.java     | 14 +++++
 .../jbang/core/commands/tui/HttpTabRenderTest.java | 18 ++++++
 .../jbang/core/commands/tui/TablePolishTest.java   | 64 ++++++++++++++++++++
 16 files changed, 331 insertions(+), 34 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTableTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTableTab.java
index 223193e2fa74..b839273fadbd 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTableTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTableTab.java
@@ -52,6 +52,11 @@ abstract class AbstractTableTab extends AbstractTab {
 
     protected abstract int getRowCount();
 
+    /** Whether the table selects its first row by itself: a table with a 
detail panel of the selected row does. */
+    protected boolean selectsFirstRow() {
+        return false;
+    }
+
     protected abstract void renderContent(Frame frame, Rect area, 
IntegrationInfo info);
 
     @Override
@@ -120,6 +125,11 @@ abstract class AbstractTableTab extends AbstractTab {
             renderNoSelection(frame, area);
             return;
         }
+        // a table with a detail panel has a row selected once it has rows, so 
the detail shows something
+        int rows = getRowCount();
+        if (selectsFirstRow() && rows > 0 && (tableState.selected() == null || 
tableState.selected() >= rows)) {
+            tableState.select(tableState.selected() == null ? 0 : rows - 1);
+        }
         renderContent(frame, area, info);
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActivityTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActivityTab.java
index 0cfbdca0549c..59b5bbcccb9c 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActivityTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActivityTab.java
@@ -54,7 +54,8 @@ class ActivityTab extends AbstractTableTab {
     private static final long[] TIME_FILTER_MILLIS = { 0, 60_000, 300_000, 
900_000, 1_800_000, 3_600_000 };
 
     ActivityTab(MonitorContext ctx) {
-        super(ctx, "exchange", "route", "elapsed", "since");
+        // newest first, as a live feed reads
+        super(ctx, "since", "exchange", "route", "elapsed");
     }
 
     @Override
@@ -144,13 +145,13 @@ class ActivityTab extends AbstractTableTab {
 
             rows.add(Row
                     .from(
-                            Cell.from(ae.exchangeId != null ? ae.exchangeId : 
""),
+                            Cell.from(shortExchangeId(ae.exchangeId)),
                             Cell.from(Span.styled(ae.routeId != null ? 
ae.routeId : "", Style.EMPTY.fg(Theme.accent()))),
                             Cell.from(Span.styled(status, statusStyle)),
                             Cell.from(elapsed),
                             Cell.from(sends),
                             Cell.from(ago),
-                            Cell.from(ae.fromEndpointUri != null ? 
ae.fromEndpointUri : "")));
+                            Cell.from(ae.fromEndpointUri != null ? 
TuiHelper.displayUri(ae.fromEndpointUri) : "")));
         }
 
         if (rows.isEmpty()) {
@@ -166,11 +167,12 @@ class ActivityTab extends AbstractTableTab {
 
         List<Constraint> constraints = new ArrayList<>();
         constraints.add(Constraint.length(4));
+        constraints.add(Constraint.fill());
+        List<Line> detailLines = showDetail ? detailLines(selectedEntry) : 
List.of();
         if (showDetail) {
-            constraints.add(Constraint.length(13));
-            constraints.add(Constraint.fill());
-        } else {
-            constraints.add(Constraint.fill());
+            // the detail is as tall as what it has to say, up to half of the 
tab; the table takes the rest
+            int detailHeight = Math.max(6, Math.min(detailLines.size() + 2, 
(area.height() - 4) / 2));
+            constraints.add(Constraint.length(detailHeight));
         }
         List<Rect> chunks = Layout.vertical()
                 .constraints(constraints)
@@ -189,7 +191,7 @@ class ActivityTab extends AbstractTableTab {
                         Cell.from(Span.styled(sortLabel("SINCE", "since"), 
sortStyle("since"))),
                         Cell.from(Span.styled("ENDPOINT", 
Style.EMPTY.bold()))))
                 .widths(
-                        Constraint.length(38),
+                        Constraint.length(16),
                         Constraint.length(20),
                         Constraint.length(8),
                         Constraint.length(10),
@@ -207,7 +209,7 @@ class ActivityTab extends AbstractTableTab {
         renderScrollbar(frame, table, sorted.size());
 
         if (showDetail) {
-            renderDetail(frame, chunks.get(2), selectedEntry);
+            renderDetail(frame, chunks.get(2), detailLines);
         }
     }
 
@@ -243,11 +245,11 @@ class ActivityTab extends AbstractTableTab {
         String errorRate = total > 0
                 ? String.format(Locale.US, "%.1f%%", (failed * 100.0) / total) 
: "0%";
         String rate = "";
-        if (oldestTs < Long.MAX_VALUE && newestTs > oldestTs) {
-            double minutes = (newestTs - oldestTs) / 60_000.0;
-            if (minutes > 0) {
-                rate = String.format(Locale.US, "%.1f/min", total / minutes);
-            }
+        if (oldestTs < Long.MAX_VALUE) {
+            // over the time up to now, at least a second: a burst of 
exchanges within one second is not thousands a
+            // minute
+            double minutes = Math.max(1000, System.currentTimeMillis() - 
oldestTs) / 60_000.0;
+            rate = String.format(Locale.US, "%.1f/min", total / minutes);
         }
 
         int sends = entries.stream().mapToInt(ae -> 
ae.endpointSends.size()).sum();
@@ -300,11 +302,19 @@ class ActivityTab extends AbstractTableTab {
         TuiHelper.hintLast(spans, "w", "wrap" + (wordWrap ? " [on]" : " 
[off]"));
     }
 
-    private void renderDetail(Frame frame, Rect area, ActivityEntry ae) {
+    private void renderDetail(Frame frame, Rect area, List<Line> lines) {
+        int[] scroll = { detailScroll };
+        int[] hScroll = { detailHScroll };
+        HistoryTab.renderDetailPanel(frame, area, lines, wordWrap, hScroll, 
scroll, detailScrollState, " Detail ");
+        detailScroll = scroll[0];
+        detailHScroll = hScroll[0];
+    }
+
+    private static List<Line> detailLines(ActivityEntry ae) {
         List<Line> lines = new ArrayList<>();
 
         HistoryTab.addExchangeInfoLines(lines,
-                ae.exchangeId, ae.routeId, null, null, "Endpoint", 
ae.fromEndpointUri,
+                ae.exchangeId, ae.routeId, null, null, "Endpoint", 
TuiHelper.displayUri(ae.fromEndpointUri),
                 ae.elapsed, null, ae.failed);
 
         if (ae.exceptionMessage != null) {
@@ -316,16 +326,12 @@ class ActivityTab extends AbstractTableTab {
             for (ActivityEntry.EndpointSendEntry se : ae.endpointSends) {
                 lines.add(Line
                         .from(
-                        Span.styled("   " + (se.endpointUri != null ? 
se.endpointUri : ""), Style.EMPTY),
+                        Span.styled("   " + (se.endpointUri != null ? 
TuiHelper.displayUri(se.endpointUri) : ""),
+                                Style.EMPTY),
                         Span.styled("  " + se.elapsed + "ms", Theme.muted())));
             }
         }
-
-        int[] scroll = { detailScroll };
-        int[] hScroll = { detailHScroll };
-        HistoryTab.renderDetailPanel(frame, area, lines, wordWrap, hScroll, 
scroll, detailScrollState, " Detail ");
-        detailScroll = scroll[0];
-        detailHScroll = hScroll[0];
+        return lines;
     }
 
     private List<ActivityEntry> filteredActivity(IntegrationInfo info) {
@@ -340,6 +346,22 @@ class ActivityTab extends AbstractTableTab {
         return result;
     }
 
+    /**
+     * The part of an exchange id that tells the exchanges apart: Camel ids 
are the context's prefix and a counter
+     * (AD50EEC2B14A8A3-0000000000000001), so the counter without its leading 
zeros (…-1). The detail shows the full id.
+     */
+    static String shortExchangeId(String id) {
+        if (id == null) {
+            return "";
+        }
+        int dash = id.lastIndexOf('-');
+        if (dash < 0 || dash == id.length() - 1) {
+            return id;
+        }
+        String counter = id.substring(dash + 1).replaceFirst("^0+(?=.)", "");
+        return id.substring(0, Math.min(dash, 6)) + "…-" + counter;
+    }
+
     private int sortActivity(ActivityEntry a, ActivityEntry b) {
         int result = switch (sort) {
             case "exchange" -> TuiHelper.compareStr(a.exchangeId, 
b.exchangeId);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
index 0976b4c7b94e..136a02e2b69d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
@@ -105,6 +105,11 @@ class EndpointsTab extends AbstractTableTab {
         this.perEndpointOutSizeHistory = 
metrics.getPerEndpointOutSizeHistory();
     }
 
+    @Override
+    protected boolean selectsFirstRow() {
+        return true;
+    }
+
     @Override
     protected int getRowCount() {
         IntegrationInfo info = ctx.findSelectedIntegration();
@@ -249,9 +254,9 @@ class EndpointsTab extends AbstractTableTab {
                 cells.add(rightCell(FlowHelper.sizeToString(ep.meanBodySize), 
10));
                 
cells.add(rightCell(FlowHelper.sizeToString(ep.meanHeadersSize), 10));
             }
-            cells.add(centerCell(ep.stub ? "x" : "", 6));
-            cells.add(centerCell(ep.remote ? "x" : "", 8));
-            cells.add(Cell.from(ep.uri != null ? ep.uri : ""));
+            cells.add(centerCell(ep.stub ? TuiIcons.CHECK : "", 6));
+            cells.add(centerCell(ep.remote ? TuiIcons.CHECK : "", 8));
+            cells.add(Cell.from(ep.uri != null ? TuiHelper.displayUri(ep.uri) 
: ""));
             rows.add(Row.from(cells));
         }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
index ab5923980a1e..fe735789bf79 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
@@ -73,6 +73,11 @@ class ErrorsTab extends AbstractTableTab {
         super(ctx, "id", "age", "route", "node", "exception");
     }
 
+    @Override
+    protected boolean selectsFirstRow() {
+        return true;
+    }
+
     @Override
     protected int getRowCount() {
         return filteredSize();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTab.java
index fdf62c8560cf..7de92f93a077 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTab.java
@@ -113,9 +113,10 @@ class HealthTab extends AbstractTableTab {
                     Cell.from("")));
         }
 
+        // KIND says what the check is for: R readiness (may take traffic), L 
liveness (is alive)
         String title = showOnlyDown
                 ? " Health [DOWN only] "
-                : " Health ";
+                : " Health ─ KIND: R readiness, L liveness ";
 
         Table table = Table.builder()
                 .rows(rows)
@@ -144,7 +145,7 @@ class HealthTab extends AbstractTableTab {
     @Override
     public void renderFooter(List<Span> spans) {
         super.renderFooter(spans);
-        hint(spans, "d", "toggle DOWN");
+        hint(spans, "d", showOnlyDown ? "show all" : "only DOWN");
     }
 
     boolean isShowOnlyDown() {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index 4b6fed1c60ac..f16e8018fb42 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -2133,7 +2133,7 @@ class HistoryTab extends AbstractTab {
         spans.add(Span.styled("status:" + (failed ? "failed" : "ok"),
                 failed ? Theme.error().bold() : Theme.success().bold()));
         if (last.elapsed >= 0) {
-            spans.add(Span.raw(" elapsed:" + 
TimeUtils.printDuration(last.elapsed, true)));
+            spans.add(Span.raw(" elapsed:" + 
TuiHelper.formatDurationMs(last.elapsed)));
         }
         if (first.epochMs > 0) {
             String ago = TimeUtils.printSince(first.epochMs);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
index b7a465333646..4ad817609b03 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -73,6 +74,11 @@ class HttpTab extends AbstractTableTab {
         this.probe = new HttpProbe(ctx);
     }
 
+    @Override
+    protected boolean selectsFirstRow() {
+        return true;
+    }
+
     @Override
     protected int getRowCount() {
         return sortedVisibleEndpoints(ctx.findSelectedIntegration()).size();
@@ -314,6 +320,14 @@ class HttpTab extends AbstractTableTab {
             if (ep.management && !showManagement) {
                 continue;
             }
+            if (filter == 0 && !ep.fromRest) {
+                // a REST service is served by a platform-http consumer of its 
own: one row for both
+                HttpEndpointInfo rest = restFor(info, ep);
+                if (rest != null) {
+                    rest.hits = Math.max(rest.hits, ep.hits);
+                    continue;
+                }
+            }
             if (filter == 1 && !ep.fromRest) {
                 continue;
             }
@@ -325,6 +339,21 @@ class HttpTab extends AbstractTableTab {
         return result;
     }
 
+    /** The REST service with the same method and path as a plain HTTP 
endpoint, or null. */
+    static HttpEndpointInfo restFor(IntegrationInfo info, HttpEndpointInfo 
http) {
+        for (HttpEndpointInfo ep : info.httpEndpoints) {
+            if (ep.fromRest && !ep.specification && Objects.equals(ep.path, 
http.path)
+                    && Objects.equals(normalizeMethod(ep.method), 
normalizeMethod(http.method))) {
+                return ep;
+            }
+        }
+        return null;
+    }
+
+    private static String normalizeMethod(String method) {
+        return method != null ? method.trim().toUpperCase(Locale.ENGLISH) : "";
+    }
+
     static Style methodStyle(String method) {
         if (method == null) {
             return Style.EMPTY;
@@ -350,7 +379,8 @@ class HttpTab extends AbstractTableTab {
         }
         long restCount = info.httpEndpoints.stream().filter(e -> e.fromRest && 
!e.specification).count();
         long specCount = info.httpEndpoints.stream().filter(e -> 
e.specification).count();
-        long httpCount = info.httpEndpoints.stream().filter(e -> !e.fromRest 
&& !e.management).count();
+        long httpCount = info.httpEndpoints.stream()
+                .filter(e -> !e.fromRest && !e.management && restFor(info, e) 
== null).count();
         long mgmtCount = info.httpEndpoints.stream().filter(e -> 
e.management).count();
         if (restCount > 0) {
             spans.add(Span.raw("  "));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 071a4624249b..c4457e82549d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -159,6 +159,26 @@ class McpFacade {
     }
 
     // Tab name constants
+    /**
+     * The index of a main tab by its name, by the label the tab bar shows 
(Route, Endpoint) or by its number key, else
+     * -1.
+     */
+    static int tabIndex(String tabName) {
+        if (tabName == null) {
+            return -1;
+        }
+        String name = tabName.trim();
+        for (int i = 0; i < TAB_NAMES.length; i++) {
+            String tab = TAB_NAMES[i];
+            String label = tab.endsWith("s") && !"Errors".equals(tab) ? 
tab.substring(0, tab.length() - 1) : tab;
+            String key = String.valueOf((i + 1) % 10);
+            if (tab.equalsIgnoreCase(name) || label.equalsIgnoreCase(name) || 
key.equals(name)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
     static final String[] TAB_NAMES = {
             "Overview", "Source", "Log", "Activity", "Diagram",
             "Routes", "Endpoints", "Inspect", "Errors", "More"
@@ -375,6 +395,11 @@ class McpFacade {
     // ---- Tab navigation ----
 
     String navigateToTab(String tabName) {
+        int index = tabIndex(tabName);
+        if (index >= 0) {
+            bridge.handleTabKey(index);
+            return TAB_NAMES[index];
+        }
         for (int i = 0; i < TAB_NAMES.length; i++) {
             if (TAB_NAMES[i].equalsIgnoreCase(tabName)) {
                 bridge.handleTabKey(i);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 8b3cc0321f01..e0f121cdc16d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -962,7 +962,7 @@ class RoutesTab extends AbstractTab {
         if (showDescription && route.description != null && 
!route.description.isBlank()) {
             return route.description;
         }
-        return route.from != null ? route.from : "";
+        return route.from != null ? TuiHelper.displayUri(route.from) : "";
     }
 
     /**
@@ -1140,7 +1140,8 @@ class RoutesTab extends AbstractTab {
             }
             rows.add(Row.from(
                     Cell.from("   route"),
-                    Cell.from(Span.styled(route.from != null ? route.from : 
route.routeId, routeStyle)),
+                    Cell.from(Span.styled(route.from != null ? 
TuiHelper.displayUri(route.from) : route.routeId,
+                            routeStyle)),
                     rightCell(formatThroughput(route.throughput), 8),
                     rightCell(String.valueOf(route.total), 8),
                     rightCell(String.valueOf(route.failed), 6,
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 e9cebdb3cdeb..c41fc8e2a530 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
@@ -25,6 +25,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
@@ -362,12 +363,31 @@ final class StatusParser {
                             hc.message = 
details.getString("failure.error.message");
                         }
                     }
-                    info.healthChecks.add(hc);
+                    addHealthCheck(info.healthChecks, hc);
                 }
             }
         }
     }
 
+    /**
+     * A check that runs for both readiness and liveness (context) is reported 
once per kind: one row for it, with the
+     * worse state.
+     */
+    static void addHealthCheck(List<HealthCheckInfo> checks, HealthCheckInfo 
hc) {
+        for (HealthCheckInfo known : checks) {
+            if (Objects.equals(known.group, hc.group) && 
Objects.equals(known.name, hc.name)) {
+                known.readiness |= hc.readiness;
+                known.liveness |= hc.liveness;
+                if (!"DOWN".equals(known.state) && hc.state != null && 
!"UP".equals(hc.state)) {
+                    known.state = hc.state;
+                    known.message = hc.message != null ? hc.message : 
known.message;
+                }
+                return;
+            }
+        }
+        checks.add(hc);
+    }
+
     /**
      * Consumers.
      */
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
index 209997c8038c..4fd9123896f8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
@@ -17,6 +17,7 @@
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.io.IOException;
+import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -146,6 +147,22 @@ final class TuiHelper {
         return slash >= 0 ? name.substring(slash + 1) : name;
     }
 
+    /**
+     * An endpoint URI as people write it: Camel normalizes URIs with their 
placeholders and spaces percent-encoded
+     * (platform-http:///stock/%7Bsku%7D), which reads poorly in a table. Only 
for display; the URI itself stays the
+     * key.
+     */
+    static String displayUri(String uri) {
+        if (uri == null || uri.indexOf('%') < 0) {
+            return uri;
+        }
+        try {
+            return URLDecoder.decode(uri.replace("+", "%2B"), 
StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException e) {
+            return uri;
+        }
+    }
+
     static String truncate(String s, int max) {
         if (s == null) {
             return "";
@@ -583,7 +600,10 @@ final class TuiHelper {
         if (minutes > 0) {
             return minutes + "m" + (seconds % 60) + "s";
         }
-        return seconds + "s" + (ms % 1000) + "ms";
+        // 2.02s reads better than 2s20ms; a tenth is enough from 10 seconds on
+        return seconds < 10
+                ? String.format(Locale.US, "%.2fs", ms / 1000.0)
+                : String.format(Locale.US, "%.1fs", ms / 1000.0);
     }
 
     static String formatLoad(String l1, String l5, String l15) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DisplayFormatsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DisplayFormatsTest.java
new file mode 100644
index 000000000000..22b98d2f5908
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DisplayFormatsTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * How the tables show URIs, durations and exchange ids.
+ */
+class DisplayFormatsTest {
+
+    @Test
+    void endpointUrisReadAsWritten() {
+        
assertThat(TuiHelper.displayUri("platform-http:///stock/%7Bsku%7D?httpMethodRestrict=GET";))
+                
.isEqualTo("platform-http:///stock/{sku}?httpMethodRestrict=GET";);
+        
assertThat(TuiHelper.displayUri("file://parked?fileName=%24%7Bheader.CamelFileName%7D"))
+                .isEqualTo("file://parked?fileName=${header.CamelFileName}");
+        // a plus stays a plus, and a URI that is not encoded is kept
+        assertThat(TuiHelper.displayUri("sql:select a+b from 
t%20x")).isEqualTo("sql:select a+b from t x");
+        
assertThat(TuiHelper.displayUri("timer:tick?period=1000")).isEqualTo("timer:tick?period=1000");
+        assertThat(TuiHelper.displayUri("bad%zz")).isEqualTo("bad%zz");
+        assertThat(TuiHelper.displayUri(null)).isNull();
+    }
+
+    @Test
+    void durationsReadAsSeconds() {
+        assertThat(TuiHelper.formatDurationMs(17)).isEqualTo("17ms");
+        assertThat(TuiHelper.formatDurationMs(2020)).isEqualTo("2.02s");
+        assertThat(TuiHelper.formatDurationMs(12345)).isEqualTo("12.3s");
+        assertThat(TuiHelper.formatDurationMs(125_000)).isEqualTo("2m5s");
+    }
+
+    @Test
+    void exchangeIdsByTheirCounter() {
+        
assertThat(ActivityTab.shortExchangeId("AD50EEC2B14A8A3-0000000000000001")).isEqualTo("AD50EE…-1");
+        
assertThat(ActivityTab.shortExchangeId("AD50EEC2B14A8A3-000000000000000A")).isEqualTo("AD50EE…-A");
+        
assertThat(ActivityTab.shortExchangeId("AD50EEC2B14A8A3-0000000000000000")).isEqualTo("AD50EE…-0");
+        
assertThat(ActivityTab.shortExchangeId("no-dash-")).isEqualTo("no-dash-");
+        assertThat(ActivityTab.shortExchangeId("plain")).isEqualTo("plain");
+        assertThat(ActivityTab.shortExchangeId(null)).isEmpty();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTabRenderTest.java
index d9f09dc77c34..5c99b27db1a6 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTabRenderTest.java
@@ -83,6 +83,8 @@ class EndpointsTabRenderTest {
     @Test
     void renderComponentNameInCyan() {
         addEndpoint("http", "http://example.com/api";, "out", "http-route", 5);
+        // a second row: the first is selected, and the selection highlight 
hides its colors
+        addEndpoint("http", "http://example.com/api2";, "out", "http-route", 5);
 
         EndpointsTab tab = new EndpointsTab(ctx, new MetricsCollector());
 
@@ -98,6 +100,7 @@ class EndpointsTabRenderTest {
     @Test
     void renderInDirectionColor() {
         addEndpoint("timer", "timer://tick", "in", "route1", 10);
+        addEndpoint("timer", "timer://tock", "in", "route1", 10);
 
         EndpointsTab tab = new EndpointsTab(ctx, new MetricsCollector());
 
@@ -114,6 +117,7 @@ class EndpointsTabRenderTest {
     @Test
     void renderOutDirectionInCyan() {
         addEndpoint("log", "log://output", "out", "route1", 10);
+        addEndpoint("log", "log://output2", "out", "route1", 10);
 
         EndpointsTab tab = new EndpointsTab(ctx, new MetricsCollector());
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
index f69d33048add..3c42792afda3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
@@ -30,6 +30,7 @@ import dev.tamboui.tui.event.KeyModifiers;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -93,6 +94,8 @@ class ErrorsTabRenderTest {
     @Test
     void renderErrorRouteIdInCyan() {
         addError("ID-001", "my-route", "to1", "Exception", "fail", false);
+        // a second row: the first is selected, and the selection highlight 
hides its colors
+        addError("ID-002", "my-route", "to1", "Exception", "fail", false);
 
         ErrorsTab tab = new ErrorsTab(ctx);
 
@@ -108,6 +111,7 @@ class ErrorsTabRenderTest {
     @Test
     void renderHandledTrueUsesGreenColor() {
         addError("ID-001", "route1", "to1", "Exception", "handled", true);
+        addError("ID-002", "route1", "to1", "Exception", "handled", true);
 
         ErrorsTab tab = new ErrorsTab(ctx);
 
@@ -123,6 +127,7 @@ class ErrorsTabRenderTest {
     @Test
     void renderHandledFalseUsesRedColor() {
         addError("ID-001", "route1", "to1", "Exception", "unhandled", false);
+        addError("ID-002", "route1", "to1", "Exception", "unhandled", false);
 
         ErrorsTab tab = new ErrorsTab(ctx);
 
@@ -135,6 +140,15 @@ class ErrorsTabRenderTest {
         assertTrue(foundRed, "handled=false should be rendered in LIGHT_RED");
     }
 
+    @Test
+    void theFirstErrorIsSelectedSoItsDetailShows() {
+        addError("ID-001", "route1", "to1", "Exception", "fail", false);
+        ErrorsTab tab = new ErrorsTab(ctx);
+        String rendered = renderToString(tab, 160, 30);
+        assertEquals(0, tab.tableState.selected());
+        assertTrue(rendered.contains("ID-001"), rendered);
+    }
+
     @Test
     void renderMultipleErrorsAllAppear() {
         addError("ID-AAA", "route-a", "to1", "IOException", "fail1", false);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTabRenderTest.java
index 978e06f304f6..2b8eb52a2f56 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTabRenderTest.java
@@ -30,6 +30,7 @@ import dev.tamboui.tui.event.KeyModifiers;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -195,6 +196,23 @@ class HttpTabRenderTest {
 
     // ---- Helper methods ----
 
+    @Test
+    void aRestServiceAndItsHttpConsumerAreOneRow() {
+        HttpEndpointInfo rest = addHttpEndpoint("GET", "/stock/{sku}", 
"http://0.0.0.0:8080/stock/{sku}";);
+        rest.fromRest = true;
+        HttpEndpointInfo http = addHttpEndpoint("GET", "/stock/{sku}", 
"http://0.0.0.0:8080/stock/{sku}";);
+        http.hits = 6;
+        addHttpEndpoint("POST", "/orders", "http://0.0.0.0:8080/orders";);
+
+        HttpTab tab = new HttpTab(ctx);
+        List<HttpEndpointInfo> rows = tab.sortedVisibleEndpoints(info);
+        assertEquals(2, rows.size(), "the REST service and the plain POST");
+        assertTrue(rows.contains(rest));
+        assertEquals(6, rest.hits, "the hits of its consumer");
+        String rendered = TuiTestHelper.renderToString(tab, 140, 30);
+        assertTrue(rendered.contains("REST: 1  HTTP: 1"), rendered);
+    }
+
     private HttpEndpointInfo addHttpEndpoint(String method, String path, 
String url) {
         HttpEndpointInfo ep = new HttpEndpointInfo();
         ep.method = method;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TablePolishTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TablePolishTest.java
new file mode 100644
index 000000000000..206c48b95ae0
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TablePolishTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Small things that make the tables read right: one row per health check, and 
tabs found by the label they show.
+ */
+class TablePolishTest {
+
+    @Test
+    void aCheckReportedForReadinessAndLivenessIsOneRow() {
+        List<HealthCheckInfo> checks = new ArrayList<>();
+        StatusParser.addHealthCheck(checks, check("context", "UP", true, 
false));
+        StatusParser.addHealthCheck(checks, check("route-controller", "UP", 
true, false));
+        StatusParser.addHealthCheck(checks, check("context", "DOWN", false, 
true));
+
+        assertThat(checks).hasSize(2);
+        HealthCheckInfo context = checks.get(0);
+        assertThat(context.readiness).isTrue();
+        assertThat(context.liveness).isTrue();
+        assertThat(context.state).as("the worse state").isEqualTo("DOWN");
+    }
+
+    @Test
+    void tabsAreFoundByTheLabelTheBarShows() {
+        
assertThat(McpFacade.tabIndex("Route")).isEqualTo(McpFacade.tabIndex("Routes")).isEqualTo(5);
+        assertThat(McpFacade.tabIndex("endpoint")).isEqualTo(6);
+        assertThat(McpFacade.tabIndex("Errors")).isEqualTo(8);
+        assertThat(McpFacade.tabIndex("1")).isZero();
+        assertThat(McpFacade.tabIndex("0")).isEqualTo(9);
+        assertThat(McpFacade.tabIndex("Health")).as("a More 
tab").isEqualTo(-1);
+    }
+
+    private static HealthCheckInfo check(String name, String state, boolean 
readiness, boolean liveness) {
+        HealthCheckInfo hc = new HealthCheckInfo();
+        hc.group = "camel";
+        hc.name = name;
+        hc.state = state;
+        hc.readiness = readiness;
+        hc.liveness = liveness;
+        return hc;
+    }
+}

Reply via email to