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