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 55f6acc28b9c chore: camel-jbang - Health is a page: all good in one 
look, or what is wrong first, with ready and live second by second (#29504)
55f6acc28b9c is described below

commit 55f6acc28b9c00c478ac56f24c694a0f451990e0
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 23:14:35 2026 +0100

    chore: camel-jbang - Health is a page: all good in one look, or what is 
wrong first, with ready and live second by second (#29504)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../core/commands/tui/DataRefreshService.java      |   6 +
 .../jbang/core/commands/tui/HealthCheckInfo.java   |   9 +
 .../dsl/jbang/core/commands/tui/HealthHistory.java |  93 +++++++
 .../dsl/jbang/core/commands/tui/HealthPage.java    | 284 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/HealthTab.java     |  42 ++-
 .../jbang/core/commands/tui/IntegrationInfo.java   |   3 +
 .../jbang/core/commands/tui/MonitorContext.java    |   2 +
 .../dsl/jbang/core/commands/tui/PopupManager.java  |   7 +
 .../dsl/jbang/core/commands/tui/StatusParser.java  |  41 ++-
 .../src/main/resources/tui/help/health.md          |  37 ++-
 .../core/commands/tui/HealthTabRenderTest.java     | 109 +++++++-
 .../jbang/core/commands/tui/StatusParserTest.java  |  20 ++
 12 files changed, 631 insertions(+), 22 deletions(-)

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 69313a523c98..a1a8f13fe86b 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
@@ -341,6 +341,9 @@ class DataRefreshService {
                     metrics.updateServiceHistory(info);
                     metrics.updateCbHistory(info);
                     metrics.updateHeapHistory(info);
+                    if (ctx != null) {
+                        ctx.healthHistory.record(info, 
System.currentTimeMillis() / 1000);
+                    }
                     metrics.updateLoadMetrics(ph, info);
                     detectReload(info);
                 }
@@ -400,6 +403,9 @@ class DataRefreshService {
                 boolean wasExplicitStop = stoppingPids.remove(prev.pid);
                 if (wasExplicitStop) {
                     metrics.removeVanished(prev.pid);
+                    if (ctx != null) {
+                        ctx.healthHistory.remove(prev.pid);
+                    }
                 } else {
                     vanishing.put(prev.pid, new VanishingInfo(prev, 
MonotonicClock.millis()));
                 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthCheckInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthCheckInfo.java
index d4988d9073f3..6545b86ae9eb 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthCheckInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthCheckInfo.java
@@ -23,4 +23,13 @@ class HealthCheckInfo {
     boolean readiness;
     boolean liveness;
     String message;
+    /** When the check went to its state (epoch millis): UP since, DOWN since; 
0 when not known. */
+    long since;
+    /** When the check was last UP (epoch millis), 0 when not known. */
+    long lastUp;
+    /** How often the check ran, and how often it failed. */
+    long invocations;
+    long failures;
+    /** When it last failed (epoch millis), 0 when it did not. */
+    long lastFailure;
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthHistory.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthHistory.java
new file mode 100644
index 000000000000..9bf0e4510e70
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthHistory.java
@@ -0,0 +1,93 @@
+/*
+ * 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.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Whether each integration was ready and live, second by second over the last 
minutes, as the TUI read its health: a
+ * check that went DOWN and came back between two looks at the Health tab is 
seen on its strip.
+ */
+final class HealthHistory {
+
+    static final int SECONDS = 300;
+    /** Not known for that second: before the TUI read the integration, or the 
integration did not say. */
+    static final byte UNKNOWN = -1;
+    static final byte READY = 1;
+    static final byte LIVE = 2;
+
+    // per pid: a ring of the last SECONDS seconds, and the second its last 
value is of
+    private final Map<String, Ring> rings = new ConcurrentHashMap<>();
+
+    private static final class Ring {
+        final byte[] values = new byte[SECONDS];
+        long last = -1;
+
+        Ring() {
+            Arrays.fill(values, UNKNOWN);
+        }
+    }
+
+    /** Records the state of the integration at the second (epoch seconds) it 
was read. */
+    void record(IntegrationInfo info, long second) {
+        if (info.pid == null || (info.healthReady == null && info.healthLive 
== null)) {
+            return;
+        }
+        // Camel reports readiness and liveness together; if only one of them 
were known, the other would be
+        // recorded as down, as a second holds one value for both
+        byte value = (byte) ((Boolean.TRUE.equals(info.healthReady) ? READY : 
0)
+                             | (Boolean.TRUE.equals(info.healthLive) ? LIVE : 
0));
+        Ring ring = rings.computeIfAbsent(info.pid, k -> new Ring());
+        synchronized (ring) {
+            if (ring.last >= 0 && second > ring.last) {
+                // the seconds in between were not read: not known
+                for (long s = ring.last + 1; s < second && s <= ring.last + 
SECONDS; s++) {
+                    ring.values[(int) (s % SECONDS)] = UNKNOWN;
+                }
+            }
+            if (second >= ring.last) {
+                ring.values[(int) (second % SECONDS)] = value;
+                ring.last = second;
+            }
+        }
+    }
+
+    /** The values of the seconds that end with the second given, oldest 
first, UNKNOWN where not known. */
+    byte[] strip(String pid, long end, int seconds) {
+        byte[] out = new byte[seconds];
+        Arrays.fill(out, UNKNOWN);
+        Ring ring = pid != null ? rings.get(pid) : null;
+        if (ring == null) {
+            return out;
+        }
+        synchronized (ring) {
+            for (int i = 0; i < seconds; i++) {
+                long s = end - seconds + 1 + i;
+                if (s <= ring.last && s > ring.last - SECONDS) {
+                    out[i] = ring.values[(int) (s % SECONDS)];
+                }
+            }
+        }
+        return out;
+    }
+
+    void remove(String pid) {
+        rings.remove(pid);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthPage.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthPage.java
new file mode 100644
index 000000000000..573acb7ce17e
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthPage.java
@@ -0,0 +1,284 @@
+/*
+ * 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.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.TimeUtils;
+
+/**
+ * The Health tab as a page: when every check is UP, that it is all good in 
one look, how long, and which checks Camel
+ * runs on its own; when one is not, the checks that are not first, what that 
means for the integration, and the other
+ * checks folded into one line. Under both, whether it was ready and live 
second by second, so a check that went DOWN
+ * and came back is seen.
+ */
+final class HealthPage {
+
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private HealthPage() {
+    }
+
+    static void render(Frame frame, Rect area, IntegrationInfo info, 
HealthHistory history, long now) {
+        List<HealthCheckInfo> checks = info.healthChecks;
+        List<HealthCheckInfo> notUp = checks.stream().filter(hc -> 
!"UP".equals(hc.state)).toList();
+        boolean notReady = Boolean.FALSE.equals(info.healthReady);
+        boolean notLive = Boolean.FALSE.equals(info.healthLive);
+        String title = notLive ? " Health โ”€ โœ— NOT LIVE " : notReady ? " Health 
โ”€ โœ— NOT READY " : " Health ";
+        Block block = 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                .borderStyle(notUp.isEmpty() ? Style.EMPTY : 
Theme.error()).title(title).build();
+        int width = Math.max(0, area.width() - 2);
+
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(Span.raw("")));
+        if (checks.isEmpty()) {
+            lines.add(center(width, Span.styled("No health checks registered", 
Theme.muted())));
+        } else if (notUp.isEmpty()) {
+            allGood(lines, info, width, now);
+        } else {
+            somethingWrong(lines, info, notUp, notReady, notLive, now);
+        }
+        lines.add(Line.from(Span.raw("")));
+        strip(lines, info, history, width, now);
+        
frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block).build(),
 area);
+    }
+
+    /** All UP: a thumbs up, since when, how often checked, and what is 
checked. */
+    private static void allGood(List<Line> lines, IntegrationInfo info, int 
width, long now) {
+        List<HealthCheckInfo> checks = info.healthChecks;
+        lines.add(center(width, Span.styled("๐Ÿ‘  All good", 
Theme.success().bold())));
+        lines.add(Line.from(Span.raw("")));
+        lines.add(center(width,
+                Span.styled("ready ", Theme.muted()), mark(info.healthReady),
+                Span.styled("    live ", Theme.muted()), mark(info.healthLive),
+                Span.styled("    " + checks.size() + (checks.size() == 1 ? " 
check UP" : " checks UP"), Theme.muted())));
+        long since = checks.stream().mapToLong(hc -> hc.since).filter(s -> s > 
0).min().orElse(0);
+        long checked = checks.stream().mapToLong(hc -> 
hc.invocations).max().orElse(0);
+        long failures = checks.stream().mapToLong(hc -> hc.failures).sum();
+        long lastFailure = checks.stream().mapToLong(hc -> 
hc.lastFailure).max().orElse(0);
+        List<String> facts = new ArrayList<>();
+        if (since > 0) {
+            facts.add("up since " + TIME.format(Instant.ofEpochMilli(since)) + 
" (" + ago(since, now) + ")");
+        }
+        if (checked > 0) {
+            facts.add("checked " + checked + " times");
+        }
+        facts.add(failures == 0
+                ? "never failed"
+                : failures + (failures == 1 ? " failure" : " failures")
+                  + (lastFailure > 0 ? ", the last " + ago(lastFailure, now) + 
" ago" : ""));
+        lines.add(center(width, Span.styled(String.join(" ยท ", facts), 
Theme.muted())));
+        lines.add(Line.from(Span.raw("")));
+        String camel = builtInSummary(checks);
+        if (!camel.isEmpty()) {
+            lines.add(Line.from(Span.styled("   Camel checks on its own: ", 
Theme.muted()), Span.raw(camel)));
+        }
+        List<HealthCheckInfo> own = checks.stream().filter(hc -> 
!isBuiltIn(hc)).toList();
+        List<Span> yours = new ArrayList<>();
+        yours.add(Span.styled("   Your checks: ", Theme.muted()));
+        if (own.isEmpty()) {
+            yours.add(Span.styled("none", Theme.muted()));
+        }
+        for (int i = 0; i < own.size(); i++) {
+            yours.add(Span.raw(i == 0 ? "" : " ยท "));
+            yours.add(Span.styled(TuiIcons.HEALTH_UP + " " + own.get(i).name, 
Theme.success()));
+        }
+        lines.add(Line.from(yours));
+    }
+
+    /** Not all UP: the checks that are not, with why and since when, what it 
means, and the rest in one line. */
+    private static void somethingWrong(
+            List<Line> lines, IntegrationInfo info, List<HealthCheckInfo> 
notUp, boolean notReady, boolean notLive,
+            long now) {
+        for (HealthCheckInfo hc : notUp) {
+            boolean down = "DOWN".equals(hc.state);
+            Style style = down ? Theme.error() : Theme.warning();
+            List<Span> head = new ArrayList<>();
+            head.add(Span.styled("  " + (down ? TuiIcons.HEALTH_DOWN : 
TuiIcons.HEALTH_WARN) + " ", style));
+            head.add(Span.styled(hc.name != null ? hc.name : "", 
style.bold()));
+            head.add(Span.styled("   " + hc.state, style));
+            if (hc.since > 0) {
+                head.add(Span.raw(" since " + 
TIME.format(Instant.ofEpochMilli(hc.since)) + " (" + ago(hc.since, now) + ")"));
+            }
+            head.add(Span.styled(" ยท " + kind(hc), Theme.muted()));
+            lines.add(Line.from(head));
+            if (hc.message != null && !hc.message.isBlank()) {
+                lines.add(Line.from(Span.raw("      " + 
TuiHelper.fixControlChars(hc.message.lines().findFirst().orElse("")))));
+            }
+            List<String> facts = new ArrayList<>();
+            if (hc.failures > 0) {
+                facts.add("failed " + hc.failures + (hc.failures == 1 ? " 
time" : " times")
+                          + (hc.invocations > 0 ? ", of " + hc.invocations + " 
checks" : ""));
+            }
+            if (hc.lastUp > 0) {
+                facts.add("last up " + 
TIME.format(Instant.ofEpochMilli(hc.lastUp)));
+            }
+            if (!facts.isEmpty()) {
+                lines.add(Line.from(Span.styled("      " + String.join(" ยท ", 
facts), Theme.muted())));
+            }
+            lines.add(Line.from(Span.raw("")));
+        }
+        if (notLive) {
+            lines.add(Line.from(Span.styled("  โœ— not live", 
Theme.error().bold()),
+                    Span.raw(" โ€” Kubernetes would restart the integration")));
+        } else if (notReady) {
+            lines.add(Line.from(Span.styled("  live ", Theme.muted()), 
mark(info.healthLive),
+                    Span.raw(" โ€” the integration runs, but Kubernetes would 
take it out of the service until the checks"
+                             + " above are UP again")));
+        }
+        int up = info.healthChecks.size() - notUp.size();
+        if (up > 0) {
+            lines.add(Line.from(Span.styled("  " + up + (up == 1 ? " other 
check UP" : " other checks UP"),
+                    Theme.muted())));
+        }
+    }
+
+    /** Ready and live, second by second over the last minutes, as the TUI 
read them. */
+    private static void strip(List<Line> lines, IntegrationInfo info, 
HealthHistory history, int width, long now) {
+        int label = 10;
+        int seconds = Math.max(10, Math.min(HealthHistory.SECONDS, width - 
label - 24));
+        long end = now / 1000;
+        byte[] values = history.strip(info.pid, end, seconds);
+        lines.add(stripLine("ready", values, HealthHistory.READY, label, 
downNote(values, HealthHistory.READY, end)));
+        lines.add(stripLine("live", values, HealthHistory.LIVE, label, 
downNote(values, HealthHistory.LIVE, end)));
+        String from = TIME.format(Instant.ofEpochSecond(end - seconds + 1));
+        lines.add(Line.from(Span.styled(" ".repeat(label) + from
+                                        + " ".repeat(Math.max(1, seconds - 
from.length() - 3)) + "now",
+                Theme.muted())));
+    }
+
+    private static Line stripLine(String name, byte[] values, byte bit, int 
label, String note) {
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.styled(String.format("%-" + label + "s", "   " + name), 
Theme.label()));
+        for (byte v : values) {
+            if (v == HealthHistory.UNKNOWN) {
+                spans.add(Span.raw(" "));
+            } else {
+                spans.add((v & bit) != 0 ? Span.styled("โ–ˆ", Theme.success()) : 
Span.styled("โ–ˆ", Theme.error()));
+            }
+        }
+        if (note != null) {
+            spans.add(Span.styled("  " + note, Theme.warning()));
+        }
+        return Line.from(spans);
+    }
+
+    /** How long it was down in the strip and when it last was, when it was: a 
flap the page would not show. */
+    static String downNote(byte[] values, byte bit, long end) {
+        int down = 0;
+        int last = -1;
+        for (int i = 0; i < values.length; i++) {
+            if (values[i] != HealthHistory.UNKNOWN && (values[i] & bit) == 0) {
+                down++;
+                last = i;
+            }
+        }
+        if (down == 0) {
+            return null;
+        }
+        long at = end - values.length + 1 + last;
+        return "down " + down + "s, the last at " + 
TIME.format(Instant.ofEpochSecond(at));
+    }
+
+    /** Whether a check is one Camel runs on its own: the context, the routes, 
their consumers and producers. */
+    static boolean isBuiltIn(HealthCheckInfo hc) {
+        String n = hc.name;
+        if (n == null || !"camel".equals(hc.group)) {
+            return false;
+        }
+        return n.equals("context") || n.equals("route-controller") || 
n.equals("security-policy")
+                || n.equals("routes") || n.equals("consumers") || 
n.equals("producers")
+                || n.startsWith("route:") || n.startsWith("consumer:") || 
n.startsWith("producer:");
+    }
+
+    /** The checks Camel runs on its own, in words: the context, the route 
controller, 2 routes and 2 consumers. */
+    static String builtInSummary(List<HealthCheckInfo> checks) {
+        List<String> parts = new ArrayList<>();
+        int routes = 0;
+        int consumers = 0;
+        int producers = 0;
+        for (HealthCheckInfo hc : checks) {
+            if (!isBuiltIn(hc)) {
+                continue;
+            }
+            String n = hc.name;
+            if (n.startsWith("route:") || n.equals("routes")) {
+                routes++;
+            } else if (n.startsWith("consumer:") || n.equals("consumers")) {
+                consumers++;
+            } else if (n.startsWith("producer:") || n.equals("producers")) {
+                producers++;
+            } else {
+                parts.add("the " + n.replace('-', ' '));
+            }
+        }
+        if (routes > 0) {
+            parts.add(routes + (routes == 1 ? " route" : " routes"));
+        }
+        if (consumers > 0) {
+            parts.add(consumers + (consumers == 1 ? " consumer" : " 
consumers"));
+        }
+        if (producers > 0) {
+            parts.add(producers + (producers == 1 ? " producer" : " 
producers"));
+        }
+        if (parts.size() <= 1) {
+            return String.join("", parts);
+        }
+        return String.join(", ", parts.subList(0, parts.size() - 1)) + " and " 
+ parts.get(parts.size() - 1);
+    }
+
+    private static String kind(HealthCheckInfo hc) {
+        return hc.readiness && hc.liveness ? "readiness and liveness" : 
hc.liveness ? "liveness" : "readiness";
+    }
+
+    private static Span mark(Boolean ok) {
+        if (ok == null) {
+            return Span.styled("?", Theme.muted());
+        }
+        return ok ? Span.styled("โœ”", Theme.success()) : Span.styled("โœ—", 
Theme.error());
+    }
+
+    private static String ago(long time, long now) {
+        return TimeUtils.printDuration(Math.max(0, now - time), false);
+    }
+
+    private static Line center(int width, Span... spans) {
+        int w = 0;
+        for (Span s : spans) {
+            w += CharWidth.of(s.content());
+        }
+        List<Span> all = new ArrayList<>();
+        all.add(Span.raw(" ".repeat(Math.max(0, (width - w) / 2))));
+        all.addAll(List.of(spans));
+        return Line.from(all);
+    }
+}
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 7de92f93a077..b3b5b995ce14 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
@@ -40,6 +40,8 @@ import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.*;
 class HealthTab extends AbstractTableTab {
 
     private boolean showOnlyDown;
+    // the page (all good, or what is wrong) unless t asks for the checks one 
by one
+    private boolean table;
 
     HealthTab(MonitorContext ctx) {
         super(ctx, "group", "name", "status");
@@ -60,7 +62,11 @@ class HealthTab extends AbstractTableTab {
 
     @Override
     protected boolean handleTabKeyEvent(KeyEvent ke) {
-        if (ke.isCharIgnoreCase('d')) {
+        if (ke.isCharIgnoreCase('t')) {
+            table = !table;
+            return true;
+        }
+        if (table && ke.isCharIgnoreCase('d')) {
             showOnlyDown = !showOnlyDown;
             return true;
         }
@@ -69,6 +75,11 @@ class HealthTab extends AbstractTableTab {
 
     @Override
     protected void renderContent(Frame frame, Rect area, IntegrationInfo info) 
{
+        if (!table) {
+            lastTableArea = null;
+            HealthPage.render(frame, area, info, ctx.healthHistory, 
System.currentTimeMillis());
+            return;
+        }
         List<HealthCheckInfo> healthChecks = new 
ArrayList<>(getFilteredHealthChecks(info));
         healthChecks.sort(this::sortHealth);
 
@@ -144,8 +155,20 @@ class HealthTab extends AbstractTableTab {
 
     @Override
     public void renderFooter(List<Span> spans) {
-        super.renderFooter(spans);
-        hint(spans, "d", showOnlyDown ? "show all" : "only DOWN");
+        if (table) {
+            super.renderFooter(spans);
+        } else {
+            // the page has nothing to sort
+            hint(spans, "Esc", "back");
+        }
+        hint(spans, "t", table ? "summary" : "all checks");
+        if (table) {
+            hint(spans, "d", showOnlyDown ? "show all" : "only DOWN");
+        }
+    }
+
+    boolean isTable() {
+        return table;
     }
 
     boolean isShowOnlyDown() {
@@ -202,6 +225,12 @@ class HealthTab extends AbstractTableTab {
         }
         JsonObject result = new JsonObject();
         result.put("tab", "Health");
+        if (info.healthReady != null) {
+            result.put("ready", info.healthReady);
+        }
+        if (info.healthLive != null) {
+            result.put("live", info.healthLive);
+        }
         JsonArray rows = new JsonArray();
         List<HealthCheckInfo> checks = new 
ArrayList<>(getFilteredHealthChecks(info));
         checks.sort(this::sortHealth);
@@ -215,6 +244,13 @@ class HealthTab extends AbstractTableTab {
             if (hi.message != null) {
                 row.put("message", hi.message);
             }
+            row.put("builtIn", HealthPage.isBuiltIn(hi));
+            if (hi.since > 0) {
+                row.put("since", hi.since);
+            }
+            if (hi.failures > 0) {
+                row.put("failures", hi.failures);
+            }
             rows.add(row);
         }
         result.put("rows", rows);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index 0d427e81ec74..bc5304001ff8 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
@@ -110,6 +110,9 @@ class IntegrationInfo {
     int routeControllerExhaustedRoutes;
     final List<RouteControllerInfo> routeControllerRoutes = new ArrayList<>();
     final List<HealthCheckInfo> healthChecks = new ArrayList<>();
+    // whether the integration is ready (may take traffic) and live (runs), as 
its health checks say; null when not known
+    Boolean healthReady;
+    Boolean healthLive;
     final List<EndpointInfo> endpoints = new ArrayList<>();
     final List<ServiceInfo> services = new ArrayList<>();
     final List<CircuitBreakerInfo> circuitBreakers = new ArrayList<>();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index ec72b5b31007..c131107ef264 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -102,6 +102,8 @@ class MonitorContext {
     final ChaosMarks chaosMarks = new ChaosMarks();
     // what happened second by second, for the Timeline screen (CAMEL-26691)
     final Timeline timeline = new Timeline();
+    // whether the integrations were ready and live, second by second, for the 
strip of the Health tab
+    final HealthHistory healthHistory = new HealthHistory();
     BiConsumer<String, String> openMarkdownCallback;
     /** Opens markdown in the doc viewer scrolled to a heading: title, 
markdown, heading (may be null). */
     MarkdownOpener openMarkdownAtCallback;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
index e9a2b0cb9d2e..ff70f244d3da 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
@@ -704,6 +704,13 @@ class PopupManager {
                     badge = " " + cbOpenCount + " OPEN";
                     badgeStyle = Theme.error();
                 }
+                // the health checks that are DOWN
+                long down = "Health".equals(tab.name()) && sel != null
+                        ? sel.healthChecks.stream().filter(hc -> 
"DOWN".equals(hc.state)).count() : 0;
+                if (down > 0) {
+                    badge = " " + down + " DOWN";
+                    badgeStyle = Theme.error();
+                }
                 // the chaos rules that inject faults now, and a failed verdict
                 String chaosBadge = "Chaos".equals(tab.name()) ? 
ChaosTab.badge(sel) : null;
                 if (chaosBadge != null) {
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 9f4e818bc66b..ace32c6c4231 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
@@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 import java.net.URI;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -381,6 +383,8 @@ final class StatusParser {
         if (healthChecks != null) {
             Boolean rdy = (Boolean) healthChecks.get("ready");
             info.ready = Boolean.TRUE.equals(rdy) ? "1/1" : "0/1";
+            info.healthReady = rdy;
+            info.healthLive = healthChecks.get("live") instanceof Boolean live 
? live : null;
             JsonArray checks = (JsonArray) healthChecks.get("checks");
             if (checks != null) {
                 for (Object c : checks) {
@@ -392,11 +396,12 @@ final class StatusParser {
                     hc.readiness = cj.getBooleanOrDefault("readiness", false);
                     hc.liveness = cj.getBooleanOrDefault("liveness", false);
                     hc.message = cj.getString("message");
-                    if (hc.message == null) {
-                        JsonObject details = (JsonObject) cj.get("details");
-                        if (details != null && 
details.containsKey("failure.error.message")) {
-                            hc.message = 
details.getString("failure.error.message");
-                        }
+                    JsonObject details = (JsonObject) cj.get("details");
+                    if (hc.message == null && details != null && 
details.containsKey("failure.error.message")) {
+                        hc.message = 
details.getString("failure.error.message");
+                    }
+                    if (details != null) {
+                        healthDetails(hc, details);
                     }
                     addHealthCheck(info.healthChecks, hc);
                 }
@@ -404,6 +409,27 @@ final class StatusParser {
         }
     }
 
+    /** When the check went to its state, when it was last UP, and how often 
it ran and failed, from its details. */
+    static void healthDetails(HealthCheckInfo hc, JsonObject details) {
+        hc.since = epochMillis(details.getString("UP".equals(hc.state) ? 
"success.start.time" : "failure.start.time"));
+        hc.lastUp = epochMillis(details.getString("success.time"));
+        hc.lastFailure = epochMillis(details.getString("failure.time"));
+        hc.invocations = TuiHelper.objToLong(details.get("invocation.count"));
+        hc.failures = TuiHelper.objToLong(details.get("failure.count"));
+    }
+
+    /** A time as the health check details have it 
(2026-11-09T17:02:42.44+01:00[Europe/Copenhagen]); 0 when not. */
+    static long epochMillis(String time) {
+        if (time == null || time.isBlank()) {
+            return 0;
+        }
+        try {
+            return ZonedDateTime.parse(time).toInstant().toEpochMilli();
+        } catch (DateTimeParseException e) {
+            return 0;
+        }
+    }
+
     /**
      * A check that runs for both readiness and liveness (context) is reported 
once per kind: one row for it, with the
      * worse state.
@@ -413,9 +439,14 @@ final class StatusParser {
             if (Objects.equals(known.group, hc.group) && 
Objects.equals(known.name, hc.name)) {
                 known.readiness |= hc.readiness;
                 known.liveness |= hc.liveness;
+                known.invocations = Math.max(known.invocations, 
hc.invocations);
+                known.failures = Math.max(known.failures, hc.failures);
+                known.lastFailure = Math.max(known.lastFailure, 
hc.lastFailure);
                 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;
+                    known.since = hc.since;
+                    known.lastUp = hc.lastUp;
                 }
                 return;
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/health.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/health.md
index 21fdc9a45d96..4a578fcdf2c6 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/health.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/health.md
@@ -4,6 +4,40 @@ Health checks verify that the integration and its dependencies 
are
 functioning correctly. They are essential for container orchestration
 platforms like Kubernetes, but also useful for local monitoring.
 
+## The Page
+
+The tab opens on a page that says how the integration is in one look.
+
+When every check is UP:
+
+```
+                        ๐Ÿ‘  All good
+
+          ready โœ”    live โœ”    7 checks UP
+   up since 16:43:05 (19m) ยท checked 2329 times ยท never failed
+
+   Camel checks on its own: the context, the route controller, the security 
policy, 2 routes and 2 consumers
+   Your checks: none
+```
+
+The checks Camel runs on its own are summed up in one line; the checks
+of your own (a group other than `camel`, or a name Camel does not use)
+are named one by one, as they are the ones you wrote.
+
+When a check is not UP, the title says what it means (`NOT READY`,
+`NOT LIVE`) and the page shows those checks first: since when, the
+message, how often it failed and when it was last UP, then what it
+means for the integration (not ready: taken out of the service; not
+live: restarted), and the other checks in one line.
+
+Under both, `ready` and `live` are drawn second by second over the last
+minutes, as the TUI read them: a check that went DOWN and came back
+between two looks at the tab shows there, with how long it was down.
+
+Press `t` for the checks one by one in the table below, and `d` there
+for only the DOWN ones. The More menu has a `DOWN` badge on Health while
+a check is DOWN.
+
 ## Table Columns
 
 - **GROUP** โ€” Category of the health check: `camel` (core checks), `routes` 
(per-route health), `consumers` (consumer health)
@@ -74,4 +108,5 @@ when to restart it.
 - `Up/Down` โ€” select health check
 - `s` โ€” cycle sort column
 - `S` โ€” reverse sort order
-- `d` โ€” show only the checks that are DOWN, or all of them again
+- `t` โ€” the checks one by one in a table, or the page again
+- `d` โ€” in the table: show only the checks that are DOWN, or all of them again
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
index 2eb1fd32269e..0d0cfe888c83 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
@@ -64,7 +64,7 @@ class HealthTabRenderTest {
         hc.liveness = true;
         info.healthChecks.add(hc);
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 100, 20);
 
         assertTrue(rendered.contains("context"), "Should render health check 
name 'context'");
@@ -82,7 +82,7 @@ class HealthTabRenderTest {
         hc.message = "Route stopped";
         info.healthChecks.add(hc);
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 120, 20);
 
         assertTrue(rendered.contains("timer-route"), "Should render health 
check name");
@@ -99,7 +99,7 @@ class HealthTabRenderTest {
         hc.readiness = true;
         info.healthChecks.add(hc);
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
 
         Rect area = new Rect(0, 0, 100, 20);
         Buffer buffer = Buffer.empty(area);
@@ -136,7 +136,7 @@ class HealthTabRenderTest {
         hc.liveness = true;
         info.healthChecks.add(hc);
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
 
         Rect area = new Rect(0, 0, 100, 20);
         Buffer buffer = Buffer.empty(area);
@@ -166,7 +166,7 @@ class HealthTabRenderTest {
     @Test
     void renderEmptyHealthChecksShowsPlaceholder() {
         // No health checks added
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         // Use wider terminal to avoid column truncation of the placeholder 
text
         String rendered = renderToString(tab, 140, 10);
 
@@ -180,7 +180,7 @@ class HealthTabRenderTest {
         addHealthCheck("camel", "route-controller", "UP");
         addHealthCheck("routes", "timer-route", "DOWN");
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 120, 20);
 
         assertTrue(rendered.contains("context"), "Should render 'context' 
check");
@@ -192,7 +192,7 @@ class HealthTabRenderTest {
     void renderShowsHeaderColumns() {
         addHealthCheck("camel", "context", "UP");
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 120, 20);
 
         assertTrue(rendered.contains("GROUP"), "Should show GROUP header");
@@ -206,7 +206,7 @@ class HealthTabRenderTest {
     void renderShowsBorderWithTitle() {
         addHealthCheck("camel", "context", "UP");
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 120, 20);
 
         assertTrue(rendered.contains("Health"), "Should show 'Health' in the 
block title");
@@ -216,7 +216,7 @@ class HealthTabRenderTest {
     void renderNoSelectionShowsPrompt() {
         ctx.selectedPid = null;
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 100, 10);
 
         assertTrue(rendered.contains("No integration selected") || 
rendered.contains("Select an integration"),
@@ -233,7 +233,7 @@ class HealthTabRenderTest {
         hc.liveness = true;
         info.healthChecks.add(hc);
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         String rendered = renderToString(tab, 120, 20);
 
         assertTrue(rendered.contains("R/L"), "Should show R/L for 
readiness+liveness");
@@ -244,7 +244,7 @@ class HealthTabRenderTest {
         addHealthCheck("camel", "context", "UP");
         addHealthCheck("routes", "failing", "DOWN");
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
 
         // Press 'd' to toggle DOWN-only filter
         tab.handleKeyEvent(KeyEvent.ofChar('d', KeyModifiers.NONE));
@@ -260,7 +260,7 @@ class HealthTabRenderTest {
 
     @Test
     void renderFooterHintsContainExpectedKeys() {
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
         List<Span> footerSpans = new ArrayList<>();
         tab.renderFooter(footerSpans);
 
@@ -277,7 +277,7 @@ class HealthTabRenderTest {
     void sortCycleChangesHeaderAppearance() {
         addHealthCheck("camel", "context", "UP");
 
-        HealthTab tab = new HealthTab(ctx);
+        HealthTab tab = tableTab();
 
         // Default sort is "name" โ€” header should have a sort indicator on NAME
         String rendered1 = renderToString(tab, 120, 20);
@@ -291,8 +291,91 @@ class HealthTabRenderTest {
                 "After one sort cycle, indicator should move to STATUS");
     }
 
+    @Test
+    void allGoodIsAPageNotATableOfUp() {
+        info.healthReady = true;
+        info.healthLive = true;
+        long now = System.currentTimeMillis();
+        for (String name : List.of("context", "route-controller", 
"security-policy", "route:orders", "route:billing",
+                "consumer:orders")) {
+            HealthCheckInfo hc = check("camel", name, "UP");
+            hc.since = now - 19 * 60_000;
+            hc.invocations = 2329;
+        }
+        check("acme", "warehouse", "UP");
+
+        String rendered = renderToString(new HealthTab(ctx), 160, 30);
+
+        assertTrue(rendered.contains("All good"), rendered);
+        assertTrue(rendered.contains("7 checks UP"), rendered);
+        assertTrue(rendered.contains("checked 2329 times ยท never failed"), 
rendered);
+        assertTrue(rendered.contains(
+                "Camel checks on its own: the context, the route controller, 
the security policy, 2 routes and 1 consumer"),
+                rendered);
+        assertTrue(rendered.contains("Your checks: " + TuiIcons.HEALTH_UP + " 
warehouse"), rendered);
+        assertFalse(rendered.contains("GROUP"), "no table: " + rendered);
+    }
+
+    @Test
+    void aCheckThatIsDownComesFirstWithWhatItMeans() {
+        info.healthReady = false;
+        info.healthLive = true;
+        long now = System.currentTimeMillis();
+        check("camel", "context", "UP");
+        HealthCheckInfo down = check("camel", "consumer:orders", "DOWN");
+        down.message = "Connection refused: kafka:9092";
+        down.since = now - 4 * 60_000;
+        down.failures = 23;
+        down.invocations = 251;
+
+        String rendered = renderToString(new HealthTab(ctx), 160, 30);
+
+        assertTrue(rendered.contains("NOT READY"), rendered);
+        assertTrue(rendered.contains("consumer:orders   DOWN since"), 
rendered);
+        assertTrue(rendered.contains("Connection refused: kafka:9092"), 
rendered);
+        assertTrue(rendered.contains("failed 23 times, of 251 checks"), 
rendered);
+        assertTrue(rendered.contains("Kubernetes would take it out of the 
service"), rendered);
+        assertTrue(rendered.contains("1 other check UP"), rendered);
+    }
+
+    @Test
+    void theStripShowsAFlapThePageWouldNotShow() {
+        info.healthReady = true;
+        info.healthLive = true;
+        check("camel", "context", "UP");
+        long end = System.currentTimeMillis() / 1000;
+        for (long s = end - 60; s <= end; s++) {
+            // ready went down for 5 seconds 30 seconds ago
+            info.healthReady = s < end - 35 || s > end - 31;
+            ctx.healthHistory.record(info, s);
+        }
+        info.healthReady = true;
+
+        String rendered = renderToString(new HealthTab(ctx), 160, 30);
+
+        assertTrue(rendered.contains("All good"), rendered);
+        assertTrue(rendered.contains("down 5s, the last at"), rendered);
+    }
+
     // ---- Helper methods ----
 
+    /** The tab with the checks one by one in the table (t). */
+    private HealthTab tableTab() {
+        HealthTab tab = new HealthTab(ctx);
+        tab.handleKeyEvent(KeyEvent.ofChar('t', KeyModifiers.NONE));
+        return tab;
+    }
+
+    private HealthCheckInfo check(String group, String name, String state) {
+        HealthCheckInfo hc = new HealthCheckInfo();
+        hc.group = group;
+        hc.name = name;
+        hc.state = state;
+        hc.readiness = true;
+        info.healthChecks.add(hc);
+        return hc;
+    }
+
     private void addHealthCheck(String group, String name, String state) {
         HealthCheckInfo hc = new HealthCheckInfo();
         hc.group = group;
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 974a290dcd93..dc90a7f944f5 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
@@ -624,4 +624,24 @@ class StatusParserTest {
         assertEquals("C:\\new", StatusParser.bodyText("C:\\\\new"), "an 
escaped backslash stays one backslash");
         assertNull(StatusParser.bodyText(null));
     }
+
+    @Test
+    void healthDetailsSayWhenAndHowOften() {
+        HealthCheckInfo hc = new HealthCheckInfo();
+        hc.state = "UP";
+        JsonObject details = new JsonObject();
+        details.put("success.start.time", 
"2026-11-09T16:43:05.255900034+01:00[Europe/Copenhagen]");
+        details.put("success.time", 
"2026-11-09T17:02:42.440624884+01:00[Europe/Copenhagen]");
+        details.put("invocation.count", "2329");
+        details.put("failure.count", "0");
+
+        StatusParser.healthDetails(hc, details);
+
+        assertEquals(1794238985255L, hc.since);
+        assertEquals(1794240162440L, hc.lastUp);
+        assertEquals(2329, hc.invocations);
+        assertEquals(0, hc.failures);
+        assertEquals(0, hc.lastFailure);
+        assertEquals(0, StatusParser.epochMillis("not a time"));
+    }
 }

Reply via email to