This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch chore-jbang-about-layout-error-count in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7a0e2efbfaf82e23ccb331d2d26efe75011749b9 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 9 00:11:44 2026 +0100 chore: camel-jbang - About: room around the sections, the number of errors that happened, and examples selected once started - About on a tall screen: a blank line between the sections and inside the panels, the In, Routes and Out panels apart, and the status lines in line with the panels. The strips of What happened are as wide as the screen allows (a column a whole number of seconds) and up to two rows high, and the time before the integration started is blank, as the sparklines have it. A short screen keeps the compact layout. - The number of errors: the error registry keeps only the newest few entries of each kind of error, so its size, which the status file carried, is not the number of errors (13 errors of two kinds showed as 6). The CLI connector now adds to the status file the errors that happened and the kinds that happened most; the Errors badge and the Watch line of About show them, falling back to the size for an older integration. The key to the Errors tab on the Watch line is a key hint ("9 show errors"), not a bare 9 next to the count. - An example run from the F2 menu or tui_run_example is selected once it starts: it is launched by its catalog name (fail-well/circuit-breaker) and names itself without the category, so it never was. - LaunchManagerOutcomeTest moves the home directory of the CLI and is now isolated from the tests running in parallel, which made it fail now and then. 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();
