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"));
+ }
}