This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26608-chaos-screen in repository https://gitbox.apache.org/repos/asf/camel.git
commit d316f95aa7ae8306ca6bbd4e8e1e71a570e8bdda Author: Claus Ibsen <[email protected]> AuthorDate: Fri Nov 6 15:12:17 2026 +0100 CAMEL-26608: camel-jbang - a chaos screen: the rules, their live state and the report of a running integration Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/images/jbang/camel-tui-chaos.png | Bin 0 -> 597980 bytes .../ROOT/pages/camel-jbang-configuration.adoc | 2 +- .../ROOT/pages/camel-jbang-tui-observe.adoc | 22 + .../modules/ROOT/pages/camel-jbang-tui.adoc | 5 +- .../camel-jbang-configuration-metadata.json | 2 +- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 4 +- .../dsl/jbang/core/commands/tui/ChaosInfo.java | 324 ++++++++++ .../dsl/jbang/core/commands/tui/ChaosTab.java | 675 +++++++++++++++++++++ .../jbang/core/commands/tui/IntegrationInfo.java | 2 + .../dsl/jbang/core/commands/tui/PopupManager.java | 6 + .../dsl/jbang/core/commands/tui/StatusParser.java | 1 + .../dsl/jbang/core/commands/tui/TabRegistry.java | 5 + .../dsl/jbang/core/commands/tui/TuiIcons.java | 1 + .../dsl/jbang/core/commands/tui/TuiToolGroups.java | 2 +- .../src/main/resources/tui/help/chaos.md | 54 ++ .../core/commands/tui/AiPanelToolGroupsTest.java | 3 +- .../core/commands/tui/ChaosTabRenderTest.java | 222 +++++++ 17 files changed, 1322 insertions(+), 8 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos.png new file mode 100644 index 000000000000..05ab6b977938 Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos.png differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc index 113638074a00..68aee5cfb6dd 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc @@ -15,7 +15,7 @@ The camel.jbang supports 54 options, which are listed below. | `camel.jbang.classpathFiles` | Additional files to add to classpath (Use commas to separate multiple files). | | String | `camel.jbang.compileWorkDir` | Work directory for compiler. Can be used to write compiled classes or other resources. | .camel-jbang/compile | String | `camel.jbang.console` | Developer console at /q/dev on local HTTP server (port 8080 by default) | false | boolean -| `camel.jbang.dataDir` | Folders under the source directory (comma separated) that hold data the routes read or write, + not sources: in dev mode a change in them does not reload the routes. Use this for an + inbox, outbox or archive folder under the source directory. | | String +| `camel.jbang.dataDir` | Folders under the source directory (comma separated) that hold data the routes read or write, not sources: in dev mode a change in them does not reload the routes. Use this for an inbox, outbox or archive folder under the source directory. | | String | `camel.jbang.dependencies` | Additional dependencies (Use commas to separate multiple dependencies). | | String | `camel.jbang.dependencies.main` | Additional dependencies for Camel Main runtime only | | String | `camel.jbang.dependencies.quarkus` | Additional dependencies for Quarkus runtime only | | String 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 10b454908f59..b8d1566b82e0 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 @@ -240,6 +240,28 @@ right panel. While sending, the status shows a spinner. Beyond the core tabs described above, the TUI includes several specialized screens worth highlighting. +=== Chaos + +With xref:next@components:others:chaos.adoc[camel-chaos] (`camel run --chaos`), the Chaos tab (under More > +Observability) shows the faults that the chaos rules inject into the running integration, and what they do: + +* The verdict of the report (PASS or FAIL with the reason), the messages lost, the expectations (met, unmet, pending) +and the faults of all rules in the last minute. +* The rules: target, fault, the observed rate next to the rate of the rule (`24% of 25%`), how often and how long ago it +fired, its faults per second over the last minute as a sparkline, the effect of its faults on the exchanges (completed, +recovered by redelivery, handled by the error handler, failed, in flight), and a note (when it ends or starts, its +bursts, what a stall or lifecycle rule does to its route). +* The selected rule in detail: the effect as a bar, its expectation with the time left, why it skipped faults, and its +recent faults with the endpoint or step, the route and the exchange. +* The counts of the report per route: completed, failed, kept by the consumer, handled, redeliveries. + +image::jbang/camel-tui-chaos.png[The Chaos tab: a failed expectation, four rules with their live state, and the recent faults of the selected rule] + +While a rule injects faults, the title bar shows a badge on every screen (`🎲 chaos 3 rules · FAIL`), so a failure made +on purpose is not debugged as a real one. The tab only reads: add, change and remove rules with `camel cmd chaos-add`, +`chaos-change`, `chaos-remove` and `chaos-clear` (see +xref:next@components:others:chaos.adoc#_control_while_it_runs[Control while it runs]). + === CVE Audit The CVE Audit tab (under More > Project) scans all Maven dependencies on your integration's classpath diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 1096a8aabbbd..44efe2a3de04 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -80,14 +80,15 @@ to any tab, or *Shift+F2* to go to a tab by name. The *More* menu (key *0*) opens a popup with tabs organized into groups: * *Routing* -- Browse Endpoints, Consumers, HTTP, Inflight, Producers, Route Controller -* *Observability* -- Circuit Breaker, Health, JFR, Metrics, Network Services, Exchange Events, Recovery Tasks, OpenTelemetry Spans +* *Observability* -- Chaos, Circuit Breaker, Health, JFR, Metrics, Network Services, Exchange Events, Recovery Tasks, OpenTelemetry Spans * *AI* -- Semantic (definitions, expert contracts, relationships, sample evaluation, Audit and Monitoring), Ollama (listed when an Ollama server is detected) * *Data* -- JDBC DataSource, Kafka, Secrets, SQL Query, SQL Trace * *JVM* -- Classpath, Heap Memory Histogram, Memory Usage, Memory Leak, Process, Startup, Threads * *Project* -- Beans, Catalog, Configuration, CVE Audit, Maven Dependencies, Type Converters, Data Type Transformers Tabs appear dynamically based on what the integration uses. For example, the Kafka tab appears -when a Kafka component is in use, SQL tabs when a DataSource is present, Circuit Breaker when +when a Kafka component is in use, SQL tabs when a DataSource is present, Chaos when the integration has +xref:next@components:others:chaos.adoc[camel-chaos] (`camel run --chaos`), Circuit Breaker when resilience4j is in use, Spans when OpenTelemetry is enabled, and JFR when `camel-jfr` is on the classpath. The TUI adapts to show only what's relevant to your integration. diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-configuration-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-configuration-metadata.json index 6bf4114bd978..6ba62ce930ac 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-configuration-metadata.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-configuration-metadata.json @@ -9,7 +9,7 @@ { "name": "camel.jbang.classpathFiles", "required": false, "description": "Additional files to add to classpath (Use commas to separate multiple files).", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.compileWorkDir", "required": false, "description": "Work directory for compiler. Can be used to write compiled classes or other resources.", "label": "advanced", "type": "string", "javaType": "String", "defaultValue": ".camel-jbang\/compile", "secret": false }, { "name": "camel.jbang.console", "required": false, "description": "Developer console at \/q\/dev on local HTTP server (port 8080 by default)", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false }, - { "name": "camel.jbang.dataDir", "required": false, "description": "Folders under the source directory (comma separated) that hold data the routes read or write, + not sources: in dev mode a change in them does not reload the routes. Use this for an + inbox, outbox or archive folder under the source directory.", "label": "advanced", "type": "string", "javaType": "String", "secret": false }, + { "name": "camel.jbang.dataDir", "required": false, "description": "Folders under the source directory (comma separated) that hold data the routes read or write, not sources: in dev mode a change in them does not reload the routes. Use this for an inbox, outbox or archive folder under the source directory.", "label": "advanced", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.dependencies", "required": false, "description": "Additional dependencies (Use commas to separate multiple dependencies).", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.dependencies.main", "required": false, "description": "Additional dependencies for Camel Main runtime only", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.dependencies.quarkus", "required": false, "description": "Additional dependencies for Quarkus runtime only", "type": "string", "javaType": "String", "secret": false }, diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 7240c404c0a0..6482766a6d24 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -1875,8 +1875,8 @@ public class CamelMonitor extends CamelCommand { monitorNotification = null; } } - // Right-aligned header spans (web server indicator) - List<Span> headerRight = new ArrayList<>(); + // Right-aligned header spans (the chaos badge, web server indicator) + List<Span> headerRight = new ArrayList<>(ChaosTab.headerBadge(ctx.findSelectedIntegration())); if (web && webBackend == null) { headerRight.add(Span.styled("web :" + webPort + " ", Theme.notice().underlined().hyperlink("http://127.0.0.1:" + webPort + "/"))); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java new file mode 100644 index 000000000000..d0a63c9ca646 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java @@ -0,0 +1,324 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +/** + * The chaos section of the status of an integration (the {@code chaos} dev console of camel-chaos): whether chaos is + * on, the current rules with their live state, and the report (verdict, counts per route, recent faults). Parsed + * leniently: a camel-chaos of another version may leave fields out, which then read as unknown (-1 or null). + */ +final class ChaosInfo { + + boolean enabled; + // why chaos is off + String reason; + String verdict; + boolean isFinal = true; + long seed; + boolean seedGiven; + long lost; + final List<String> reasons = new ArrayList<>(); + final List<Rule> rules = new ArrayList<>(); + final List<RouteCounts> routes = new ArrayList<>(); + final List<RecentFault> recentFaults = new ArrayList<>(); + + /** A current rule, with its live state. */ + static final class Rule { + String id; + int version; + String text; + String state; + long matches; + long fired; + long skipped; + final Map<String, Long> skippedReasons = new LinkedHashMap<>(); + long lostAtDlq = -1; + long endsIn = -1; + long startsIn = -1; + double rate = -1; + double observedRate = -1; + long nextFireIn = -1; + long burstEndsIn = -1; + long nextBurstIn = -1; + long lastFiredAgo = -1; + long faultsLastMinute; + long[] faultsPerSecond = new long[0]; + // the effect of its faults; faulted is 0 until it faulted an exchange + long faulted; + long completed; + long recovered; + long handled; + long failed; + long inFlight; + // the expectation, or null + String expectRoute; + String expectKind; + String expectOp; + long expectCount; + long expectValue; + String expectOutcome; + long expectTimeLeft = -1; + // what a stall or lifecycle rule did to its route, or null + String lifecycleAction; + String lifecycleRoute; + String lifecycleState; + int lifecycleRound; + int lifecycleRepeat; + long lifecycleActsIn = -1; + long lifecycleBackIn = -1; + String lifecycleReason; + + /** The target of the rule as it is written, such as {@code endpoint=kafka:orders*}. */ + String target() { + for (String token : tokens()) { + if (token.startsWith("endpoint=") || token.startsWith("component=") || token.startsWith("route=") + || token.startsWith("step=")) { + return token; + } + } + return ""; + } + + /** + * The fault of the rule with its parameters, as it is written, without the target and the options of the rule + * (at, rate, until, from, expect, within), such as {@code error status=503}. + */ + String fault() { + StringBuilder sb = new StringBuilder(); + for (String token : tokens()) { + int eq = token.indexOf('='); + String key = eq > 0 ? token.substring(0, eq) : null; + if (token.equals(target()) || key != null && OPTIONS.contains(key)) { + continue; + } + if (!sb.isEmpty()) { + sb.append(' '); + } + sb.append(token); + } + return sb.toString(); + } + + /** The options at, rate, until and from as they are written, for the RATE column. */ + String option(String name) { + for (String token : tokens()) { + if (token.startsWith(name + "=")) { + return token.substring(name.length() + 1); + } + } + return null; + } + + /** The rule split at the spaces that are not inside a quoted value. */ + List<String> tokens() { + List<String> answer = new ArrayList<>(); + if (text == null) { + return answer; + } + StringBuilder current = new StringBuilder(); + char quote = 0; + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (quote != 0) { + current.append(ch); + if (ch == quote) { + quote = 0; + } + } else if (ch == ' ' || ch == '\t') { + if (!current.isEmpty()) { + answer.add(current.toString()); + current.setLength(0); + } + } else { + if (ch == '"' || ch == '\'') { + quote = ch; + } + current.append(ch); + } + } + if (!current.isEmpty()) { + answer.add(current.toString()); + } + return answer; + } + + boolean isActive() { + return "active".equals(state) || "waiting".equals(state); + } + } + + // the options of a rule besides its target and its fault + private static final List<String> OPTIONS = List.of("at", "rate", "until", "from", "expect", "within"); + + /** The counts of a route in the report. */ + record RouteCounts(String route, long completed, long failed, long failedKept, long handled, long redeliveries) { + } + + /** A recent fault of a rule. */ + record RecentFault(long ago, String rule, String fault, String hook, String route, String where, String exchangeId) { + } + + /** The active (and waiting) rules. */ + long activeRules() { + return rules.stream().filter(Rule::isActive).count(); + } + + /** The faults of all rules in the last minute. */ + long faultsLastMinute() { + return rules.stream().mapToLong(r -> r.faultsLastMinute).sum(); + } + + /** The current rule with the id, or null. */ + Rule rule(String id) { + for (Rule r : rules) { + if (r.id != null && r.id.equals(id)) { + return r; + } + } + return null; + } + + /** + * Parses the chaos section of the status. + * + * @return the chaos info, or null when there is no section (the integration has no camel-chaos) + */ + static ChaosInfo parse(JsonObject chaos) { + if (chaos == null) { + return null; + } + ChaosInfo info = new ChaosInfo(); + info.enabled = chaos.getBooleanOrDefault("enabled", false); + info.reason = chaos.getString("reason"); + if (chaos.get("rules") instanceof JsonArray rules) { + for (Object o : rules) { + if (o instanceof JsonObject jo) { + info.rules.add(rule(jo)); + } + } + } + if (chaos.get("report") instanceof JsonObject report) { + info.verdict = report.getString("verdict"); + info.isFinal = report.getBooleanOrDefault("final", true); + info.seed = num(report.get("seed"), 0); + info.seedGiven = report.getBooleanOrDefault("seedGiven", false); + info.lost = num(report.get("lost"), 0); + if (report.get("reasons") instanceof JsonArray reasons) { + reasons.forEach(r -> info.reasons.add(String.valueOf(r))); + } + if (report.get("routes") instanceof JsonArray routes) { + for (Object o : routes) { + if (o instanceof JsonObject r) { + info.routes.add(new RouteCounts( + r.getString("route"), num(r.get("completed"), 0), num(r.get("failed"), 0), + num(r.get("failedKeptByConsumer"), 0), num(r.get("handled"), 0), + num(r.get("redeliveries"), 0))); + } + } + } + if (report.get("recentFaults") instanceof JsonArray faults) { + for (Object o : faults) { + if (o instanceof JsonObject f) { + String where = f.getString("endpoint") != null ? f.getString("endpoint") : f.getString("step"); + info.recentFaults.add(new RecentFault( + num(f.get("ago"), -1), f.getString("rule"), f.getString("fault"), f.getString("hook"), + f.getString("route"), where, f.getString("exchangeId"))); + } + } + } + } + return info; + } + + private static Rule rule(JsonObject jo) { + Rule r = new Rule(); + r.id = jo.getString("id"); + r.version = (int) num(jo.get("version"), 1); + r.text = jo.getString("rule"); + r.state = jo.getString("state"); + r.matches = num(jo.get("matches"), 0); + if (jo.get("fired") instanceof Map<?, ?> fired) { + for (Object v : fired.values()) { + r.fired += num(v, 0); + } + } + r.skipped = num(jo.get("skipped"), 0); + if (jo.get("skippedReasons") instanceof Map<?, ?> reasons) { + reasons.forEach((k, v) -> r.skippedReasons.put(String.valueOf(k), num(v, 0))); + } + r.lostAtDlq = num(jo.get("lostAtDlq"), -1); + r.endsIn = num(jo.get("endsIn"), -1); + r.startsIn = num(jo.get("startsIn"), -1); + r.rate = dbl(jo.get("rate")); + r.observedRate = dbl(jo.get("observedRate")); + r.nextFireIn = num(jo.get("nextFireIn"), -1); + r.burstEndsIn = num(jo.get("burstEndsIn"), -1); + r.nextBurstIn = num(jo.get("nextBurstIn"), -1); + r.lastFiredAgo = num(jo.get("lastFiredAgo"), -1); + r.faultsLastMinute = num(jo.get("faultsLastMinute"), 0); + if (jo.get("faultsPerSecond") instanceof List<?> perSecond) { + r.faultsPerSecond = new long[perSecond.size()]; + for (int i = 0; i < perSecond.size(); i++) { + r.faultsPerSecond[i] = num(perSecond.get(i), 0); + } + } + if (jo.get("effect") instanceof JsonObject e) { + r.faulted = num(e.get("faulted"), 0); + r.completed = num(e.get("completed"), 0); + r.recovered = num(e.get("recovered"), 0); + r.handled = num(e.get("handled"), 0); + r.failed = num(e.get("failed"), 0); + r.inFlight = num(e.get("inFlight"), 0); + } + if (jo.get("expectation") instanceof JsonObject e) { + r.expectRoute = e.getString("route"); + r.expectKind = e.getStringOrDefault("kind", "completed"); + r.expectOp = e.getStringOrDefault("op", ">="); + r.expectCount = num(e.get("count"), 0); + r.expectValue = num(e.get("value") != null ? e.get("value") : e.get("completed"), 0); + r.expectOutcome = e.getString("outcome"); + r.expectTimeLeft = num(e.get("timeLeft"), -1); + } + if (jo.get("lifecycle") instanceof JsonObject l) { + r.lifecycleAction = l.getString("action"); + r.lifecycleRoute = l.getString("route"); + r.lifecycleState = l.getString("state"); + r.lifecycleRound = (int) num(l.get("round"), 0); + r.lifecycleRepeat = (int) num(l.get("repeat"), 1); + r.lifecycleActsIn = num(l.get("actsIn"), -1); + r.lifecycleBackIn = num(l.get("backIn"), -1); + r.lifecycleReason = l.getString("reason"); + } + return r; + } + + private static long num(Object value, long otherwise) { + return value instanceof Number n ? n.longValue() : otherwise; + } + + private static double dbl(Object value) { + return value instanceof Number n ? n.doubleValue() : -1; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java new file mode 100644 index 000000000000..373e956a82a0 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java @@ -0,0 +1,675 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.table.Cell; +import dev.tamboui.widgets.table.Row; +import dev.tamboui.widgets.table.Table; +import dev.tamboui.widgets.table.TableState; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint; + +/** + * The chaos screen (camel-chaos): whether chaos is on, the verdict of the report, the current rules with their live + * state (the observed rate, when a rule last fired, its faults per second over the last minute as a sparkline, and the + * effect of its faults on the exchanges), the selected rule in detail with its recent faults, and the counts per route + * of the report. It only reads: rules are added, changed and removed with {@code camel cmd chaos-*}. + */ +class ChaosTab extends AbstractTableTab { + + private static final String SPARK = "▁▂▃▄▅▆▇█"; + // the width of the sparkline in the rules table: the 60 seconds, 4 per column + private static final int TABLE_SPARK = 15; + private static final int ROUTES_WIDTH = 46; + + ChaosTab(MonitorContext ctx) { + super(ctx); + } + + @Override + protected boolean selectsFirstRow() { + return true; + } + + @Override + protected int getRowCount() { + IntegrationInfo info = ctx.findSelectedIntegration(); + return info != null && info.chaos != null ? info.chaos.rules.size() : 0; + } + + @Override + protected void renderContent(Frame frame, Rect area, IntegrationInfo info) { + ChaosInfo chaos = info.chaos; + if (chaos == null || !chaos.enabled) { + String why = chaos == null + ? "camel-chaos is not in this integration: run it with camel run --chaos" + : chaos.reason != null ? chaos.reason : "Chaos is off"; + frame.renderWidget(Paragraph.builder() + .text(Text.from(Line.from(Span.raw("")), Line.from(Span.styled(" " + why, Theme.muted())))) + .block(block(" Chaos ")) + .build(), area); + return; + } + int tableHeight = Math.max(5, Math.min(chaos.rules.size() + 3, area.height() / 3)); + List<Rect> rows = Layout.vertical() + .constraints(Constraint.length(4), Constraint.length(tableHeight), Constraint.fill()) + .split(area); + renderVerdict(frame, rows.get(0), chaos); + renderRules(frame, rows.get(1), chaos, info.name); + List<Rect> bottom = Layout.horizontal() + .constraints(Constraint.fill(), Constraint.length(ROUTES_WIDTH)) + .split(rows.get(2)); + renderDetail(frame, bottom.get(0), chaos, selectedRule(chaos)); + renderRoutes(frame, bottom.get(1), chaos); + } + + private ChaosInfo.Rule selectedRule(ChaosInfo chaos) { + Integer sel = tableState.selected(); + return sel != null && sel >= 0 && sel < chaos.rules.size() ? chaos.rules.get(sel) : null; + } + + // ---- the verdict ---- + + private static void renderVerdict(Frame frame, Rect area, ChaosInfo chaos) { + boolean fail = "FAIL".equals(chaos.verdict); + List<Span> first = new ArrayList<>(); + first.add(Span.raw(" ")); + first.add(Span.styled(chaos.verdict != null ? chaos.verdict : "-", + (fail ? Theme.error() : Theme.success()).bold())); + first.add(Span.raw(" ")); + first.add(Span.styled("lost ", Theme.muted())); + first.add(Span.styled(String.valueOf(chaos.lost), chaos.lost > 0 ? Theme.error() : Style.EMPTY)); + long met = 0; + long unmet = 0; + long pending = 0; + long waiting = 0; + for (ChaosInfo.Rule r : chaos.rules) { + if ("met".equals(r.expectOutcome)) { + met++; + } else if ("unmet".equals(r.expectOutcome)) { + unmet++; + } else if ("pending".equals(r.expectOutcome)) { + pending++; + } + if ("waiting".equals(r.state)) { + waiting++; + } + } + if (met + unmet + pending > 0) { + first.add(Span.styled(" expectations ", Theme.muted())); + first.add(Span.styled(met + " met", Theme.success())); + first.add(Span.raw(" · ")); + first.add(Span.styled(unmet + " unmet", unmet > 0 ? Theme.error() : Style.EMPTY)); + first.add(Span.raw(" · ")); + first.add(Span.styled(pending + " pending", pending > 0 ? Theme.warning() : Style.EMPTY)); + } + first.add(Span.styled(" faults ", Theme.muted())); + first.add(Span.raw(String.valueOf(chaos.faultsLastMinute()))); + first.add(Span.styled(" in the last minute", Theme.muted())); + first.add(Span.styled(" rules ", Theme.muted())); + first.add(Span.raw((chaos.activeRules() - waiting) + " active" + + (waiting > 0 ? " · " + waiting + " waiting" : ""))); + // the seed is in the title; the reason of a failed verdict, or what the verdict waits for + Line second; + if (!chaos.reasons.isEmpty()) { + second = Line.from(Span.styled(" " + TuiIcons.CROSS + " " + chaos.reasons.get(0) + + (chaos.reasons.size() > 1 ? " (+" + (chaos.reasons.size() - 1) + " more)" : ""), + Theme.error())); + } else if (!chaos.isFinal) { + second = Line.from(Span.styled(" not final: an expectation is pending", Theme.muted())); + } else { + second = Line.from(Span.raw("")); + } + frame.renderWidget(Paragraph.builder() + .text(Text.from(Line.from(first), second)) + .block(block(" Chaos ─ on ─ seed " + chaos.seed + " ")) + .build(), area); + } + + // ---- the rules ---- + + private void renderRules(Frame frame, Rect area, ChaosInfo chaos, String name) { + List<Row> rows = new ArrayList<>(); + for (ChaosInfo.Rule r : chaos.rules) { + rows.add(Row.from( + Cell.from(Span.styled(r.id + (r.version > 1 ? " v" + r.version : ""), Style.EMPTY.fg(Theme.accent()))), + Cell.from(Span.styled(r.state != null ? r.state : "", stateStyle(r.state))), + Cell.from(target(r)), + Cell.from(r.fault()), + Cell.from(rate(r)), + rightCell(String.valueOf(r.fired), 5), + rightCell(r.lastFiredAgo >= 0 ? shortTime(r.lastFiredAgo) : "-", 6), + Cell.from(Span.styled(sparkline(r.faultsPerSecond, TABLE_SPARK), Theme.warning())), + Cell.from(Line.from(effect(r))), + Cell.from(Span.styled(note(r), Theme.muted())))); + } + if (rows.isEmpty()) { + frame.renderWidget(Paragraph.builder() + .text(Text.from(Line.from(Span.styled( + " No chaos rules: add one with camel cmd chaos-add " + (name != null ? name : "<name>") + + " --rule='...'", + Theme.muted())))) + .block(block(" Rules [0] ")) + .build(), area); + return; + } + Style bold = Style.EMPTY.bold(); + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled("ID", bold)), + Cell.from(Span.styled("STATE", bold)), + Cell.from(Span.styled("TARGET", bold)), + Cell.from(Span.styled("FAULT", bold)), + Cell.from(Span.styled("RATE", bold)), + rightCell("FIRED", 5, bold), + rightCell("LAST", 6, bold), + Cell.from(Span.styled("FAULTS/S", bold)), + Cell.from(Span.styled("EFFECT", bold)), + Cell.from(Span.styled("NOTE", bold)))) + .widths( + Constraint.length(6), + Constraint.length(8), + Constraint.fill(7), + Constraint.fill(5), + Constraint.length(16), + Constraint.length(5), + Constraint.length(6), + Constraint.length(TABLE_SPARK), + Constraint.length(12), + Constraint.fill(4)) + .highlightStyle(Theme.selectionBg()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .block(block(" Rules [" + chaos.rules.size() + "] ")) + .build(); + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderScrollbar(frame, table, chaos.rules.size()); + } + + /** + * The target of a rule for the table: the URI pattern of an {@code endpoint=} target (the most common one), else + * the kind and the pattern, such as {@code route poller}. + */ + static String target(ChaosInfo.Rule r) { + String t = r.target(); + if (t.startsWith("endpoint=")) { + return t.substring(9); + } + int eq = t.indexOf('='); + return eq > 0 ? t.substring(0, eq) + " " + t.substring(eq + 1) : t; + } + + /** + * The observed rate next to the rate of the rule, such as {@code 24% of 25%}, {@code 1 in 4} or {@code 5s in 30s}. + */ + static String rate(ChaosInfo.Rule r) { + if (r.lifecycleAction != null) { + return r.lifecycleRepeat > 1 ? "round " + r.lifecycleRound + " of " + r.lifecycleRepeat : ""; + } + String option = r.option("rate"); + String target; + if (option != null && option.startsWith("burst:")) { + target = option.substring(6).replace("/", " in "); + } else if (option != null && option.startsWith("every:")) { + target = "1 in " + option.substring(6); + } else if (r.rate >= 0 && r.rate < 1) { + target = percent(r.rate); + } else { + return "all"; + } + return r.observedRate >= 0 ? percent(r.observedRate) + " of " + target : target; + } + + /** The effect of the faults of a rule, compact: completed, recovered, handled, failed and in flight. */ + static List<Span> effect(ChaosInfo.Rule r) { + List<Span> spans = new ArrayList<>(); + if (r.faulted <= 0) { + return spans; + } + addCount(spans, r.completed, "✓", Theme.success()); + addCount(spans, r.recovered, "↩", Theme.warning()); + addCount(spans, r.handled, "⇩", Theme.error()); + addCount(spans, r.failed, TuiIcons.CROSS, Theme.error().bold()); + addCount(spans, r.inFlight, "…", Theme.muted()); + return spans; + } + + private static void addCount(List<Span> spans, long count, String mark, Style style) { + if (count > 0) { + if (!spans.isEmpty()) { + spans.add(Span.raw(" ")); + } + spans.add(Span.styled(count + mark, style)); + } + } + + /** What a person should know about a rule besides its counts: when it starts or ends, its bursts, its route. */ + static String note(ChaosInfo.Rule r) { + if (r.lifecycleState != null) { + // the route is the target of the rule; between the rounds of repeat= the route is back + if (r.lifecycleRound > 0 && r.lifecycleActsIn >= 0) { + return "next round in " + shortTime(r.lifecycleActsIn); + } + StringBuilder sb = new StringBuilder(r.lifecycleState); + if (r.lifecycleActsIn >= 0) { + sb.append(", acts in ").append(shortTime(r.lifecycleActsIn)); + } else if (r.lifecycleBackIn >= 0) { + sb.append(", back in ").append(shortTime(r.lifecycleBackIn)); + } + if (r.lifecycleReason != null) { + sb.append(": ").append(r.lifecycleReason); + } + return sb.toString(); + } + List<String> parts = new ArrayList<>(); + if (r.startsIn >= 0) { + parts.add("starts in " + shortTime(r.startsIn)); + } + if (r.burstEndsIn >= 0) { + parts.add("burst, " + shortTime(r.burstEndsIn) + " left"); + } else if (r.nextBurstIn >= 0) { + parts.add("next burst in " + shortTime(r.nextBurstIn)); + } + if (r.nextFireIn > 0) { + parts.add("fires again in " + r.nextFireIn); + } + if (r.endsIn >= 0) { + parts.add("ends in " + shortTime(r.endsIn)); + } + if (r.skipped > 0) { + parts.add("skipped " + r.skipped); + } + if (r.lostAtDlq > 0) { + parts.add(r.lostAtDlq + " lost at the DLQ"); + } + return String.join(", ", parts); + } + + // ---- the selected rule ---- + + private static void renderDetail(Frame frame, Rect area, ChaosInfo chaos, ChaosInfo.Rule r) { + if (r == null) { + frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.raw("")))) + .block(block(" Rule ")).build(), area); + return; + } + Style label = Theme.muted(); + List<Line> lines = new ArrayList<>(); + int sparkWidth = Math.max(10, Math.min(60, area.width() - 20)); + lines.add(Line.from(Span.styled(" Faults/s ", label), + Span.styled(sparkline(r.faultsPerSecond, sparkWidth), Theme.warning()), + Span.styled(" " + r.faultsLastMinute + " in 60s", label))); + List<Span> rate = new ArrayList<>(); + rate.add(Span.styled(" Rate ", label)); + rate.add(Span.raw(rate(r))); + if (r.lastFiredAgo >= 0) { + rate.add(Span.styled(" last fired ", label)); + rate.add(Span.raw(shortTime(r.lastFiredAgo) + " ago")); + } + rate.add(Span.styled(" matches ", label)); + rate.add(Span.raw(String.valueOf(r.matches))); + String note = note(r); + if (!note.isEmpty()) { + rate.add(Span.styled(" " + note, label)); + } + lines.add(Line.from(rate)); + if (r.faulted > 0) { + lines.add(effectBar(r, Math.max(10, Math.min(40, area.width() - 40)))); + lines.add(Line.from(effectWords(r))); + } + if (r.expectRoute != null) { + String spec = "completed".equals(r.expectKind) && ">=".equals(r.expectOp) + ? r.expectRoute + ":" + r.expectCount + : r.expectRoute + ":" + r.expectKind + r.expectOp + r.expectCount; + Style outcome = switch (r.expectOutcome != null ? r.expectOutcome : "") { + case "met" -> Theme.success().bold(); + case "unmet" -> Theme.error().bold(); + case "pending" -> Theme.warning(); + default -> Theme.muted(); + }; + List<Span> expect = new ArrayList<>(); + expect.add(Span.styled(" Expect ", label)); + expect.add(Span.raw(spec + " ")); + expect.add(Span.styled(r.expectOutcome != null ? r.expectOutcome.toUpperCase(Locale.ENGLISH) : "-", outcome)); + expect.add(Span.raw(" " + r.expectValue + " " + r.expectKind)); + if (r.expectTimeLeft >= 0 && "pending".equals(r.expectOutcome)) { + expect.add(Span.styled(" " + shortTime(r.expectTimeLeft) + " left", label)); + } + lines.add(Line.from(expect)); + } + for (Map.Entry<String, Long> skip : r.skippedReasons.entrySet()) { + lines.add(Line.from(Span.styled(" Skipped ", label), + Span.styled(skip.getValue() + ": " + skip.getKey(), Theme.warning()))); + } + boolean first = true; + for (ChaosInfo.RecentFault f : chaos.recentFaults) { + if (!r.id.equals(f.rule())) { + continue; + } + lines.add(Line.from( + Span.styled(first ? " Recent " : " ", label), + Span.styled(String.format("%-6s", f.ago() >= 0 ? shortTime(f.ago()) : ""), label), + Span.styled("🎲 ", Theme.warning()), + Span.raw(f.fault() != null ? f.fault() : ""), + Span.styled(where(f), label))); + first = false; + } + frame.renderWidget(Paragraph.builder() + .text(Text.from(lines)) + .block(block(" " + r.id + " ─ " + r.text + " ")) + .build(), area); + } + + private static String where(ChaosInfo.RecentFault f) { + StringBuilder sb = new StringBuilder(); + if (f.hook() != null) { + sb.append(" at ").append(f.hook()); + if (f.where() != null) { + sb.append(' ').append(f.where()); + } + } + if (f.route() != null) { + sb.append(" · route ").append(f.route()); + } + if (f.exchangeId() != null) { + String id = f.exchangeId(); + // the start and the end of the id, as the Activity tab shortens it + sb.append(" · ").append(id.length() > 16 ? id.substring(0, 6) + "…" + id.substring(id.length() - 4) : id); + } + return sb.toString(); + } + + /** The effect as a bar: completed, recovered, handled, failed, and in flight, in proportion. */ + private static Line effectBar(ChaosInfo.Rule r, int width) { + long total = Math.max(1, r.faulted); + List<Span> spans = new ArrayList<>(); + spans.add(Span.styled(" Effect ", Theme.muted())); + int used = 0; + long[] counts = { r.completed, r.recovered, r.handled, r.failed, r.inFlight }; + Style[] styles = { Theme.success(), Theme.warning(), Theme.error(), Theme.error().bold(), Theme.muted() }; + for (int i = 0; i < counts.length; i++) { + int n = (int) Math.round((double) counts[i] * width / total); + if (counts[i] > 0 && n == 0) { + n = 1; + } + n = Math.min(n, width - used); + if (n > 0) { + spans.add(Span.styled((i == counts.length - 1 ? "░" : "█").repeat(n), styles[i])); + used += n; + } + } + spans.add(Span.styled(" " + r.faulted + (r.faulted == 1 ? " exchange" : " exchanges"), Theme.muted())); + return Line.from(spans); + } + + private static List<Span> effectWords(ChaosInfo.Rule r) { + List<Span> spans = new ArrayList<>(); + spans.add(Span.raw(" ")); + addWords(spans, r.completed, "completed", Theme.success()); + addWords(spans, r.recovered, "recovered by redelivery", Theme.warning()); + addWords(spans, r.handled, "handled by the error handler", Theme.error()); + addWords(spans, r.failed, "failed", Theme.error().bold()); + addWords(spans, r.inFlight, "in flight", Theme.muted()); + return spans; + } + + private static void addWords(List<Span> spans, long count, String words, Style style) { + if (count > 0) { + if (spans.size() > 1) { + spans.add(Span.styled(" · ", Theme.muted())); + } + spans.add(Span.styled(count + " " + words, style)); + } + } + + // ---- the routes ---- + + private static void renderRoutes(Frame frame, Rect area, ChaosInfo chaos) { + List<Row> rows = new ArrayList<>(); + for (ChaosInfo.RouteCounts c : chaos.routes) { + rows.add(Row.from( + Cell.from(Span.styled(c.route() != null ? c.route() : "", Style.EMPTY.fg(Theme.accent()))), + rightCell(String.valueOf(c.completed()), 6), + rightCell(String.valueOf(c.failed()), 5, c.failed() > 0 ? Theme.error() : Style.EMPTY), + rightCell(String.valueOf(c.failedKept()), 5), + rightCell(String.valueOf(c.handled()), 5, c.handled() > 0 ? Theme.warning() : Style.EMPTY), + rightCell(String.valueOf(c.redeliveries()), 6))); + } + if (rows.isEmpty()) { + frame.renderWidget(Paragraph.builder() + .text(Text.from(Line.from(Span.styled(" No routes counted yet", Theme.muted())))) + .block(block(" Routes ")) + .build(), area); + return; + } + Style bold = Style.EMPTY.bold(); + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled("ROUTE", bold)), + rightCell("DONE", 6, bold), + rightCell("FAIL", 5, bold), + rightCell("KEPT", 5, bold), + rightCell("HAND", 5, bold), + rightCell("REDEL", 6, bold))) + .widths( + Constraint.fill(), + Constraint.length(6), + Constraint.length(5), + Constraint.length(5), + Constraint.length(5), + Constraint.length(6)) + .block(block(" Routes ")) + .build(); + frame.renderStatefulWidget(table, area, new TableState()); + } + + // ---- the badge ---- + + /** + * The badge of the chaos screen, in the More menu and the title bar: how many rules inject faults now, and FAIL + * when the verdict failed; null when chaos is off or there is nothing to say. + */ + static String badge(IntegrationInfo info) { + if (info == null || info.chaos == null || !info.chaos.enabled) { + return null; + } + long active = info.chaos.activeRules(); + boolean fail = "FAIL".equals(info.chaos.verdict); + if (active == 0 && !fail) { + return null; + } + String rules = active == 1 ? "1 rule" : active + " rules"; + return active > 0 ? rules + (fail ? " · FAIL" : "") : "FAIL"; + } + + /** + * The badge in the title bar, on every screen: faults are injected on purpose, so a failure made by chaos is not + * debugged as a real one. Empty when there is no badge. + */ + static List<Span> headerBadge(IntegrationInfo info) { + String badge = badge(info); + if (badge == null) { + return List.of(); + } + Style style = (badge.endsWith("FAIL") ? Theme.error() : Theme.warning()).bold(); + return List.of(Span.styled(" " + TuiIcons.TAB_CHAOS + " chaos " + badge + " ", style), Span.raw(" ")); + } + + // ---- helpers ---- + + /** + * The values as a sparkline of the width: several values share a column (the largest of them), and the height is + * relative to the largest value; a column with no fault is blank. + */ + static String sparkline(long[] values, int width) { + if (values == null || values.length == 0 || width <= 0) { + return ""; + } + int per = Math.max(1, (int) Math.ceil((double) values.length / width)); + int columns = (values.length + per - 1) / per; + long[] cols = new long[columns]; + long max = 0; + for (int i = 0; i < values.length; i++) { + cols[i / per] = Math.max(cols[i / per], values[i]); + max = Math.max(max, values[i]); + } + StringBuilder sb = new StringBuilder(); + for (long v : cols) { + if (v <= 0 || max == 0) { + sb.append(' '); + } else { + int level = (int) Math.min(SPARK.length() - 1, Math.max(0, (v * SPARK.length() - 1) / max)); + sb.append(SPARK.charAt(level)); + } + } + return sb.toString(); + } + + /** A time for a column: 800ms, 2s, 1m4s, 2h3m. */ + static String shortTime(long ms) { + if (ms < 1000) { + return ms + "ms"; + } + long s = ms / 1000; + if (s < 60) { + return s + "s"; + } + if (s < 3600) { + return (s / 60) + "m" + (s % 60 > 0 ? (s % 60) + "s" : ""); + } + return (s / 3600) + "h" + ((s % 3600) / 60 > 0 ? ((s % 3600) / 60) + "m" : ""); + } + + private static String percent(double share) { + double p = share * 100; + return p >= 10 || p == Math.rint(p) + ? String.format(Locale.US, "%.0f%%", p) : String.format(Locale.US, "%.1f%%", p); + } + + private static Style stateStyle(String state) { + return switch (state != null ? state : "") { + case "active" -> Theme.warning(); + case "waiting" -> Theme.info(); + default -> Theme.muted(); + }; + } + + private static Block block(String title) { + return Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build(); + } + + @Override + public void renderFooter(List<Span> spans) { + hint(spans, "Esc", "back"); + } + + @Override + public SelectionContext getSelectionContext() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.chaos == null || info.chaos.rules.isEmpty()) { + return null; + } + List<String> items = info.chaos.rules.stream().map(r -> r.id + ": " + r.text).toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Chaos rules"); + } + + @Override + public String description() { + return "The chaos rules of camel-chaos with their live state, and the report"; + } + + @Override + public String getHelpText() { + return DocHelper.loadHelpText("chaos"); + } + + @Override + public JsonObject getTableDataAsJson() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.chaos == null) { + return null; + } + ChaosInfo chaos = info.chaos; + JsonObject result = new JsonObject(); + result.put("tab", "Chaos"); + result.put("enabled", chaos.enabled); + if (chaos.reason != null) { + result.put("reason", chaos.reason); + } + result.put("verdict", chaos.verdict); + result.put("final", chaos.isFinal); + result.put("lost", chaos.lost); + result.put("reasons", new JsonArray(chaos.reasons)); + JsonArray rows = new JsonArray(); + for (ChaosInfo.Rule r : chaos.rules) { + JsonObject row = new JsonObject(); + row.put("id", r.id); + row.put("version", r.version); + row.put("rule", r.text); + row.put("state", r.state); + row.put("rate", rate(r)); + row.put("matches", r.matches); + row.put("fired", r.fired); + row.put("faultsLastMinute", r.faultsLastMinute); + if (r.lastFiredAgo >= 0) { + row.put("lastFiredAgo", r.lastFiredAgo); + } + if (r.faulted > 0) { + JsonObject effect = new JsonObject(); + effect.put("faulted", r.faulted); + effect.put("completed", r.completed); + effect.put("recovered", r.recovered); + effect.put("handled", r.handled); + effect.put("failed", r.failed); + effect.put("inFlight", r.inFlight); + row.put("effect", effect); + } + if (r.expectOutcome != null) { + row.put("expectation", r.expectOutcome); + } + row.put("note", note(r)); + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", chaos.rules.size()); + Integer sel = tableState.selected(); + result.put("selectedIndex", sel != null ? sel : -1); + return result; + } +} 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 77743bb194db..5c4f89ed2091 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 @@ -106,6 +106,8 @@ class IntegrationInfo { final List<EndpointInfo> endpoints = new ArrayList<>(); final List<ServiceInfo> services = new ArrayList<>(); final List<CircuitBreakerInfo> circuitBreakers = new ArrayList<>(); + // the chaos rules and report (camel-chaos), or null without camel-chaos + ChaosInfo chaos; final List<KafkaConsumerInfo> kafkaConsumers = new ArrayList<>(); int errorCount; volatile List<ErrorInfo> errors = new ArrayList<>(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java index daa072555132..e9a2b0cb9d2e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java @@ -704,6 +704,12 @@ class PopupManager { badge = " " + cbOpenCount + " OPEN"; badgeStyle = Theme.error(); } + // the chaos rules that inject faults now, and a failed verdict + String chaosBadge = "Chaos".equals(tab.name()) ? ChaosTab.badge(sel) : null; + if (chaosBadge != null) { + badge = " " + chaosBadge; + badgeStyle = chaosBadge.endsWith("FAIL") ? Theme.error() : Theme.warning(); + } List<Span> spans = new ArrayList<>(); if (keyPos >= 0 && keyPos < name.length()) { 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 5766eadbd6e4..a5e2ab416314 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 @@ -107,6 +107,7 @@ final class StatusParser { parseServices(info, root); parseInternalTasks(info, root); parseCircuitBreakers(info, root); + info.chaos = ChaosInfo.parse(root.get("chaos") instanceof JsonObject chaos ? chaos : null); // Parse Kafka consumers parseKafkaSection(root, info); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java index 3709b5d25e20..807a2740071c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -73,6 +73,7 @@ class TabRegistry { private HealthTab healthTab; private HistoryTab historyTab; private CircuitBreakerTab circuitBreakerTab; + private ChaosTab chaosTab; private ErrorsTab errorsTab; private ActivityTab activityTab; private MetricsTab metricsTab; @@ -141,6 +142,7 @@ class TabRegistry { healthTab = new HealthTab(ctx); historyTab = new HistoryTab(ctx, dataService.traces(), dataService.traceFilePositions()); circuitBreakerTab = new CircuitBreakerTab(ctx, dataService.metrics()); + chaosTab = new ChaosTab(ctx); errorsTab = new ErrorsTab(ctx); activityTab = new ActivityTab(ctx); metricsTab = new MetricsTab(ctx); @@ -190,6 +192,9 @@ class TabRegistry { "Routing", List.of(), info -> "SupervisingRouteController".equals(info.routeControllerType)), // Observability + new MoreTab( + TuiIcons.TAB_CHAOS, "Chaos", "C&haos", chaosTab, "Observability", + List.of("chaos")), new MoreTab( TuiIcons.TAB_CIRCUIT_BREAKER, "Circuit Breaker", "&Circuit Breaker", circuitBreakerTab, "Observability", diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java index ff4cac037eb7..29821e0c3dba 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java @@ -145,6 +145,7 @@ final class TuiIcons { static final String TAB_BEANS = JAVA; static final String TAB_BROWSE = MESSAGE; static final String TAB_CATALOG = "📖"; + static final String TAB_CHAOS = "🎲"; static final String TAB_CIRCUIT_BREAKER = "⚡"; static final String TAB_CLASSPATH = BUNDLED; static final String TAB_CONFIGURATION = DOCUMENT; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java index b13294f2b517..070491a70878 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java @@ -123,7 +123,7 @@ final class TuiToolGroups { // injected on purpose, so it does not hunt for a bug behind them case CHAOS -> f.chaosOn() ? "Chaos: camel-chaos is on, so some failures and delays may be injected on purpose; tui_get_status" - + " section 'chaos' has the rules." + + " section 'chaos' has the rules, tui_get_table tab 'Chaos' their live state and effect." : "Chaos: camel-chaos is in the integration but off, so it injects no faults."; }; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md new file mode 100644 index 000000000000..fa746aed9035 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md @@ -0,0 +1,54 @@ +# Chaos + +camel-chaos turns on faults in a running integration during development: +"fail 25% of the calls to the stock service", "stall the poller route +for 20 seconds". This screen shows the rules, how often they fire, what +their faults do to the exchanges, and the verdict of the report. + +Chaos is on with `camel run --chaos` (profile `dev`). Add, change and +remove rules with the Camel CLI: + +``` +camel cmd chaos-add myapp --rule='endpoint=http:stock* error status=503 rate=0.25 until=10m' +camel cmd chaos-remove myapp --id=r1 +camel cmd chaos-clear myapp --reset +``` + +While a rule injects faults, the title bar shows a badge on every +screen (`🎲 chaos 3 rules · FAIL`), so a failure made on purpose is not +debugged as a real one. + +## The verdict + +The top line has the verdict of the report: PASS, or FAIL with the +reason, such as an expectation that is unmet or messages lost at the +dead letter channel. It also shows how many messages were lost, the +expectations (met, unmet, pending), the faults of all rules in the last +minute, and the seed of the random draws. + +## Rules + +- **ID** — The id of the rule (r1, r2, ...), with its version when it was changed +- **STATE** — `active`, `waiting` (it starts later, `from=`) or `ended` +- **TARGET** — What the rule matches: `endpoint=`, `component=`, `route=` or `step=` +- **FAULT** — What it does, with its parameters, such as `error status=503` +- **RATE** — The share of the matches it fired at, next to its own: `24% of 25%`, `20% of 1 in 5`, `16% of 5s in 30s`; for a flapping route the round +- **FIRED** — How many faults it fired +- **LAST** — How long ago it last fired +- **FAULTS/S** — Its faults per second over the last minute, as a sparkline +- **EFFECT** — What its faults did to the exchanges: `✓` completed, `↩` recovered by redelivery, `⇩` handled by the error handler (such as the dead letter channel), `✗` failed, `…` in flight +- **NOTE** — When it ends or starts, its bursts, what a stall or lifecycle rule does to its route, and the faults it skipped + +## The selected rule + +The panel under the table shows the selected rule in full: its faults +per second, rate, the effect as a bar with the counts in words, its +expectation with the time left, why it skipped faults, and its recent +faults: when, the fault, where (the endpoint or step), the route and the +exchange. + +## Routes + +The counts of the report per route: completed (DONE), failed (FAIL), +failed and kept by the consumer to read again (KEPT), handled by the +error handler (HAND) and the redeliveries (REDEL). diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java index 838ed0a3d0d0..666fe4879c02 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java @@ -243,7 +243,8 @@ class AiPanelToolGroupsTest { assertEquals(List.of(ToolGroup.CHAOS), on.groups()); assertTrue(on.tools().isEmpty(), on.tools().toString()); assertEquals(List.of("Chaos: camel-chaos is on, so some failures and delays may be injected on purpose;" - + " tui_get_status section 'chaos' has the rules."), + + " tui_get_status section 'chaos' has the rules, tui_get_table tab 'Chaos' their live" + + " state and effect."), on.guidance()); TuiToolGroups.Selection off = TuiToolGroups.select( diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java new file mode 100644 index 000000000000..83315c0d47a0 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The chaos screen ({@link ChaosTab}) rendered from the chaos section of a status, as the chaos dev console of + * camel-chaos writes it. + */ +class ChaosTabRenderTest { + + // the chaos section of a status: three rules (an error with an expectation, a waiting burst, a flapping stall), the + // report with a failed expectation, the counts per route and the recent faults + private static final String CHAOS = """ + { + "enabled": true, + "rules": [ + { + "id": "r1", "version": 1, "state": "active", + "rule": "endpoint=http://localhost:8765/stock* error status=503 rate=0.25 until=10m expect=orders:handled<=0", + "hooks": ["send"], "matches": 170, "fired": {"send": 41}, "skipped": 0, "endsIn": 432000, + "rate": 0.25, "observedRate": 0.2412, "lastFiredAgo": 2000, "faultsLastMinute": 12, + "faultsPerSecond": [0,0,1,0,0,2,0,0,1,0,0,0,1,0,0,0,0,3,0,0,1,0,0,0,0,1,0,0,0,0, + 0,1,0,0,0,2,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0], + "effect": {"faulted": 38, "completed": 0, "recovered": 33, "handled": 2, "failed": 0, "inFlight": 3}, + "expectation": {"route": "orders", "kind": "handled", "op": "<=", "count": 0, "within": 120000, + "value": 2, "outcome": "unmet"} + }, + { + "id": "r2", "version": 2, "state": "waiting", + "rule": "endpoint=seda:shipping* drop rate=burst:5s/30s from=+30s", + "hooks": ["send"], "matches": 0, "fired": {"send": 0}, "skipped": 0, "startsIn": 25000, + "rate": 0.1667, "faultsLastMinute": 0 + }, + { + "id": "r3", "version": 1, "state": "active", + "rule": "route=poller stall duration=20s repeat=3 every=1m", + "hooks": [], "matches": 1, "fired": {"lifecycle": 1}, "skipped": 0, "lastFiredAgo": 12000, + "faultsLastMinute": 1, + "lifecycle": {"action": "suspend", "route": "poller", "duration": 20000, "state": "down", + "repeat": 3, "every": 60000, "round": 1, "downAt": 5, "backIn": 8000} + } + ], + "report": { + "verdict": "FAIL", "final": true, "seed": 42, "seedGiven": true, "lost": 0, + "reasons": ["route orders: 2 exchanges handled by the error handler within 2m0s, at most 0 expected (rule r1)"], + "routes": [ + {"route": "orders", "completed": 97, "failed": 0, "failedKeptByConsumer": 0, "handled": 2, + "redeliveries": 44}, + {"route": "stock", "completed": 141, "failed": 0, "failedKeptByConsumer": 0, "handled": 0, + "redeliveries": 0} + ], + "recentFaults": [ + {"timestamp": 1000, "ago": 2000, "rule": "r1", "version": 1, "fault": "error status 503", + "hook": "send", "route": "orders", "endpoint": "http://localhost:8765/stock", + "exchangeId": "3F2A-1E"}, + {"timestamp": 900, "ago": 12000, "rule": "r3", "version": 1, "fault": "stall 20s", + "route": "poller"} + ] + } + } + """; + + private MonitorContext ctx; + private IntegrationInfo info; + + @BeforeEach + void setUp() throws Exception { + Theme.resetForTesting(); + info = new IntegrationInfo(); + info.pid = "1234"; + info.name = "chaosdemo"; + AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(List.of(info)); + ctx = new MonitorContext(data, new AtomicReference<>(List.of())); + ctx.selectedPid = "1234"; + } + + private void chaos(String json) throws Exception { + info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize(json)); + } + + @Test + void theStatusParserReadsTheChaosSection() throws Exception { + JsonObject root = new JsonObject(); + root.put("context", new JsonObject(Map.of("name", "chaosdemo"))); + IntegrationInfo without = StatusParser.parseIntegration(ProcessHandle.current(), root); + assertThat(without.chaos).isNull(); + root.put("chaos", Jsoner.deserialize(CHAOS)); + IntegrationInfo parsed = StatusParser.parseIntegration(ProcessHandle.current(), root); + assertThat(parsed.chaos).isNotNull(); + assertThat(parsed.chaos.rules).hasSize(3); + assertThat(parsed.chaos.verdict).isEqualTo("FAIL"); + } + + @Test + void theVerdictTheRulesAndTheRoutes() throws Exception { + chaos(CHAOS); + String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 40); + + assertThat(screen).contains("FAIL").contains("expectations 0 met · 1 unmet · 0 pending") + .contains("faults 13 in the last minute").contains("rules 2 active · 1 waiting") + .contains("route orders: 2 exchanges handled by the error handler"); + // the rules: target, fault, observed rate next to the rate, effect, note + assertThat(screen).contains("http://localhost:8765/stock*").contains("error status=503") + .contains("24% of 25%").contains("33↩").contains("2⇩").contains("ends in 7m12s"); + assertThat(screen).contains("r2 v2").contains("waiting").contains("starts in 25s"); + assertThat(screen).contains("round 1 of 3").contains("route poller").contains("down, back in 8s"); + // the routes + assertThat(screen).contains("ROUTE").contains("orders").contains("141"); + } + + @Test + void theSelectedRuleInDetail() throws Exception { + chaos(CHAOS); + String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 40); + + assertThat(screen).contains("33 recovered by redelivery").contains("2 handled by the error handler") + .contains("3 in flight").contains("38 exchanges"); + assertThat(screen).contains("orders:handled<=0").contains("UNMET"); + assertThat(screen).contains("error status 503 at send http://localhost:8765/stock").contains("3F2A-1E"); + } + + @Test + void withoutCamelChaosOrWhenItIsOff() throws Exception { + String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15); + assertThat(none).contains("camel-chaos is not in this integration: run it with camel run --chaos"); + + chaos("{\"enabled\": false, \"reason\": \"Chaos is off: it stays off with profile prod\"}"); + assertThat(TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15)) + .contains("Chaos is off: it stays off with profile prod"); + } + + @Test + void withoutRulesItSaysHowToAddOne() throws Exception { + chaos("{\"enabled\": true, \"rules\": [], \"report\": {\"verdict\": \"PASS\", \"final\": true, \"seed\": 7}}"); + String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 20); + assertThat(screen).contains("PASS").contains("No chaos rules: add one with camel cmd chaos-add chaosdemo"); + } + + @Test + void theBadgeOfTheTitleBarAndTheMoreMenu() throws Exception { + assertThat(ChaosTab.badge(info)).isNull(); + chaos(CHAOS); + assertThat(ChaosTab.badge(info)).isEqualTo("3 rules · FAIL"); + chaos("{\"enabled\": true, \"rules\": [{\"id\": \"r1\", \"state\": \"active\", \"rule\": \"step=a error\"}]," + + " \"report\": {\"verdict\": \"PASS\"}}"); + assertThat(ChaosTab.badge(info)).isEqualTo("1 rule"); + chaos("{\"enabled\": true, \"rules\": [{\"id\": \"r1\", \"state\": \"ended\", \"rule\": \"step=a error\"}]," + + " \"report\": {\"verdict\": \"PASS\"}}"); + assertThat(ChaosTab.badge(info)).isNull(); + chaos("{\"enabled\": false}"); + assertThat(ChaosTab.badge(info)).isNull(); + chaos(CHAOS); + assertThat(ChaosTab.headerBadge(info).get(0).content()).isEqualTo(" 🎲 chaos 3 rules · FAIL "); + } + + @Test + void theTableAsJsonForTheTools() throws Exception { + chaos(CHAOS); + JsonObject json = new ChaosTab(ctx).getTableDataAsJson(); + assertThat(json.getString("verdict")).isEqualTo("FAIL"); + JsonArray rows = json.getCollection("rows"); + assertThat(rows).hasSize(3); + JsonObject r1 = (JsonObject) rows.get(0); + assertThat(r1.getString("rate")).isEqualTo("24% of 25%"); + assertThat(((Map<?, ?>) r1.get("effect")).get("recovered")).isEqualTo(33L); + assertThat(r1.getString("expectation")).isEqualTo("unmet"); + } + + @Test + void sparklineAndRates() { + long[] perSecond = new long[60]; + perSecond[0] = 1; + perSecond[59] = 4; + String spark = ChaosTab.sparkline(perSecond, 20); + assertThat(spark).hasSize(20).startsWith("▂").endsWith("█"); + assertThat(spark.substring(1, 19).isBlank()).isTrue(); + assertThat(ChaosTab.sparkline(new long[0], 20)).isEmpty(); + + assertThat(ChaosTab.shortTime(800)).isEqualTo("800ms"); + assertThat(ChaosTab.shortTime(64_000)).isEqualTo("1m4s"); + assertThat(ChaosTab.shortTime(120_000)).isEqualTo("2m"); + + ChaosInfo.Rule every = new ChaosInfo.Rule(); + every.text = "endpoint=x error rate=every:5"; + every.rate = 0.2; + every.observedRate = 0.2; + assertThat(ChaosTab.rate(every)).isEqualTo("20% of 1 in 5"); + ChaosInfo.Rule all = new ChaosInfo.Rule(); + all.text = "step=a delay duration='1s'"; + all.rate = 1; + assertThat(ChaosTab.rate(all)).isEqualTo("all"); + assertThat(all.target()).isEqualTo("step=a"); + assertThat(ChaosTab.target(all)).isEqualTo("step a"); + assertThat(all.fault()).isEqualTo("delay duration='1s'"); + } +}
